Skip to main content

yggdrasil/api/
friends.rs

1//! 友链管理:Leptos server functions。
2//!
3//! 前台 `/friends` 读取可见友链(`list_friend_links`,走 moka 缓存 + SSR 缓存失效,
4//! 与文章/标签同架构);后台 `/admin/friends` 做完整 CRUD。
5//! 鉴权走 cookie session(`get_current_admin_user`),与其它后台 server-fn 一致。
6//!
7//! 排序用整数 `sort_order`(越小越靠前);删除为物理删除(无回收站/软删除,
8//! 友链无审计需求)。
9
10#![allow(clippy::unused_unit, deprecated)]
11
12use crate::api::error::ApiError;
13use leptos::prelude::*;
14
15use crate::models::friend_link::FriendLink;
16
17#[cfg(feature = "server")]
18use crate::api::error::AppError;
19
20/// 前台友链列表(仅活跃,按 sort_order 升序)。
21///
22/// 缓存命中直返;未命中查询 `friend_links` 活跃行后写缓存。公开接口,无需登录。
23#[server(ListFriendLinks, endpoint = "list_friend_links", input = leptos::server_fn::codec::Json)]
24pub async fn list_friend_links() -> Result<Vec<FriendLink>, ApiError> {
25    #[cfg(feature = "server")]
26    {
27        use crate::api::error::AppError;
28        use crate::cache;
29        use crate::db::pool::get_conn;
30
31        if let Some(links) = cache::get_friend_links().await {
32            return Ok(links);
33        }
34        let client = get_conn().await.map_err(AppError::db_conn)?;
35        let rows = client
36            .query(
37                "SELECT id, name, url, avatar_url, description, sort_order, is_active, \
38                        created_at, updated_at \
39                 FROM friend_links \
40                 WHERE is_active \
41                 ORDER BY sort_order, id",
42                &[],
43            )
44            .await
45            .map_err(AppError::query)?;
46        let links: Vec<FriendLink> = rows.iter().map(row_to_friend_link).collect();
47        cache::set_friend_links(links.clone()).await;
48        Ok(links)
49    }
50    #[cfg(not(feature = "server"))]
51    unreachable!()
52}
53
54/// 后台友链列表(含停用项,按 sort_order 升序)。仅 admin。
55#[server(ListAllFriendLinks, endpoint = "list_all_friend_links", input = leptos::server_fn::codec::Json)]
56pub async fn list_all_friend_links() -> Result<Vec<FriendLink>, ApiError> {
57    #[cfg(feature = "server")]
58    {
59        use crate::api::auth::get_current_admin_user;
60        use crate::api::error::AppError;
61        use crate::db::pool::get_conn;
62
63        let _admin = get_current_admin_user().await?;
64        let client = get_conn().await.map_err(AppError::db_conn)?;
65        let rows = client
66            .query(
67                "SELECT id, name, url, avatar_url, description, sort_order, is_active, \
68                        created_at, updated_at \
69                 FROM friend_links \
70                 ORDER BY sort_order, id",
71                &[],
72            )
73            .await
74            .map_err(AppError::query)?;
75        Ok(rows.iter().map(row_to_friend_link).collect())
76    }
77    #[cfg(not(feature = "server"))]
78    unreachable!()
79}
80
81/// 新增友链。仅 admin。
82///
83/// 校验通过后 `INSERT ... RETURNING *`,并失效友链 moka 缓存 + `/friends` SSR 缓存
84/// + 递增全局世代号,保证前台下次访问立即看到新卡片。
85#[server(CreateFriendLink, endpoint = "create_friend_link", input = leptos::server_fn::codec::Json)]
86pub async fn create_friend_link(
87    name: String,
88    url: String,
89    avatar_url: Option<String>,
90    description: String,
91    sort_order: i32,
92) -> Result<FriendLink, ApiError> {
93    #[cfg(feature = "server")]
94    {
95        use crate::api::assets::refs;
96        use crate::api::auth::get_current_admin_user;
97        use crate::api::error::AppError;
98        use crate::db::pool::get_conn;
99
100        let _admin = get_current_admin_user().await?;
101        let avatar_url = validate_link(&name, &url, avatar_url.as_deref(), &description)?;
102        let (name, url, description) = trim_fields(name, url, description);
103
104        let mut client = get_conn().await.map_err(AppError::db_conn)?;
105        let tx = client.transaction().await.map_err(AppError::tx)?;
106        let paths: Vec<String> = refs::avatar_asset_path(avatar_url.as_deref())
107            .into_iter()
108            .collect();
109        let locked = refs::lock_assets_by_path(&tx, &paths).await?;
110        if locked.missing {
111            return Err(AppError::BadRequest(refs::MISSING_AVATAR_ASSET.to_string()).into());
112        }
113        let row = tx
114            .query_one(
115                "INSERT INTO friend_links \
116                    (name, url, avatar_url, description, sort_order) \
117                 VALUES ($1, $2, $3, $4, $5) \
118                 RETURNING id, name, url, avatar_url, description, sort_order, is_active, \
119                           created_at, updated_at",
120                &[&name, &url, &avatar_url, &description, &sort_order],
121            )
122            .await
123            .map_err(AppError::tx)?;
124        let id: i32 = row.get("id");
125        refs::sync_friend_avatar_ref(&tx, id, locked.ids.first().copied()).await?;
126        tx.commit().await.map_err(AppError::tx)?;
127        invalidate_friend_links_views();
128        Ok(row_to_friend_link(&row))
129    }
130    #[cfg(not(feature = "server"))]
131    unreachable!()
132}
133
134/// 更新友链(含启用状态)。仅 admin。
135///
136/// 行不存在返回 `AppError::NotFound`;成功后同样失效缓存与 SSR。
137#[server(UpdateFriendLink, endpoint = "update_friend_link", input = leptos::server_fn::codec::Json)]
138pub async fn update_friend_link(
139    id: i32,
140    name: String,
141    url: String,
142    avatar_url: Option<String>,
143    description: String,
144    sort_order: i32,
145    is_active: bool,
146) -> Result<FriendLink, ApiError> {
147    #[cfg(feature = "server")]
148    {
149        use crate::api::assets::refs;
150        use crate::api::auth::get_current_admin_user;
151        use crate::api::error::AppError;
152        use crate::db::pool::get_conn;
153
154        let _admin = get_current_admin_user().await?;
155        let avatar_url = validate_link(&name, &url, avatar_url.as_deref(), &description)?;
156        let (name, url, description) = trim_fields(name, url, description);
157
158        let mut client = get_conn().await.map_err(AppError::db_conn)?;
159        let tx = client.transaction().await.map_err(AppError::tx)?;
160        let Some(current) = tx
161            .query_opt(
162                "SELECT avatar_url FROM friend_links WHERE id = $1 FOR UPDATE",
163                &[&id],
164            )
165            .await
166            .map_err(AppError::query)?
167        else {
168            return Err(AppError::NotFound("友链不存在").into());
169        };
170        let current: Option<String> = current.get(0);
171        let paths: Vec<String> = refs::avatar_asset_path(avatar_url.as_deref())
172            .into_iter()
173            .collect();
174        let locked = refs::lock_assets_by_path(&tx, &paths).await?;
175        // 后台排序会对每条友链重发原头像:未改动的失效头像不阻止保存,只是不建引用行。
176        if locked.missing && avatar_url != current {
177            return Err(AppError::BadRequest(refs::MISSING_AVATAR_ASSET.to_string()).into());
178        }
179        let row = tx
180            .query_one(
181                "UPDATE friend_links \
182                 SET name = $2, url = $3, avatar_url = $4, description = $5, \
183                     sort_order = $6, is_active = $7, updated_at = NOW() \
184                 WHERE id = $1 \
185                 RETURNING id, name, url, avatar_url, description, sort_order, is_active, \
186                           created_at, updated_at",
187                &[
188                    &id,
189                    &name,
190                    &url,
191                    &avatar_url,
192                    &description,
193                    &sort_order,
194                    &is_active,
195                ],
196            )
197            .await
198            .map_err(AppError::tx)?;
199        refs::sync_friend_avatar_ref(&tx, id, locked.ids.first().copied()).await?;
200        tx.commit().await.map_err(AppError::tx)?;
201        invalidate_friend_links_views();
202        Ok(row_to_friend_link(&row))
203    }
204    #[cfg(not(feature = "server"))]
205    unreachable!()
206}
207
208/// 删除友链(物理删除)。仅 admin。
209///
210/// id 不存在时静默成功(幂等删除),成功后失效缓存与 SSR。
211#[server(DeleteFriendLink, endpoint = "delete_friend_link", input = leptos::server_fn::codec::Json)]
212pub async fn delete_friend_link(id: i32) -> Result<(), ApiError> {
213    #[cfg(feature = "server")]
214    {
215        use crate::api::auth::get_current_admin_user;
216        use crate::api::error::AppError;
217        use crate::db::pool::get_conn;
218
219        let _admin = get_current_admin_user().await?;
220        let client = get_conn().await.map_err(AppError::db_conn)?;
221        client
222            .execute("DELETE FROM friend_links WHERE id = $1", &[&id])
223            .await
224            .map_err(AppError::query)?;
225        invalidate_friend_links_views();
226        Ok(())
227    }
228    #[cfg(not(feature = "server"))]
229    unreachable!()
230}
231
232/// 是否为安全的本地素材路径:`/uploads/` 之后非空,且不含二次 `/` 开头、`..`、`\0`。
233///
234/// 友链头像与用户头像共用这条规则(见 `crate::api::profile`)。
235#[cfg(feature = "server")]
236pub(crate) fn is_local_asset_url(value: &str) -> bool {
237    let Some(path) = value.strip_prefix("/uploads/") else {
238        return false;
239    };
240    !path.is_empty() && !path.starts_with('/') && !path.contains("..") && !path.contains('\0')
241}
242
243/// 校验友链字段并返回归一化后的头像 URL。
244///
245/// 规则(全部 `AppError::BadRequest`,消息原样透传给后台表单):
246/// - `name` trim 后非空且 ≤ 64 字符;
247/// - `url` trim 后必须 `http://` / `https://` 前缀且 ≤ 512 字符(仅前缀检查,
248///   不引入完整 URL 解析);
249/// - `avatar_url`:`None` 或 trim 后为空 → 归一化为 `None`;否则必须为 http(s) 链接或
250///   安全的 `/uploads/` 素材路径;
251/// - `description` trim 后 ≤ 200 字符。
252#[cfg(feature = "server")]
253fn validate_link(
254    name: &str,
255    url: &str,
256    avatar_url: Option<&str>,
257    description: &str,
258) -> Result<Option<String>, AppError> {
259    if name.trim().is_empty() {
260        return Err(AppError::BadRequest("友链名称不能为空".to_string()));
261    }
262    if name.trim().chars().count() > 64 {
263        return Err(AppError::BadRequest(
264            "友链名称过长(上限 64 字符)".to_string(),
265        ));
266    }
267    let url = url.trim();
268    if !(url.starts_with("http://") || url.starts_with("https://")) {
269        return Err(AppError::BadRequest(
270            "友链 URL 必须为 http(s) 链接".to_string(),
271        ));
272    }
273    if url.chars().count() > 512 {
274        return Err(AppError::BadRequest(
275            "URL 过长(上限 512 字符)".to_string(),
276        ));
277    }
278    let avatar_url = match avatar_url.map(str::trim) {
279        None | Some("") => None,
280        Some(a) => {
281            let is_http_url = a.starts_with("http://") || a.starts_with("https://");
282            if !is_http_url && !is_local_asset_url(a) {
283                return Err(AppError::BadRequest(
284                    "头像 URL 必须为 http(s) 链接或 /uploads/ 素材路径".to_string(),
285                ));
286            }
287            if a.chars().count() > 512 {
288                return Err(AppError::BadRequest(
289                    "头像 URL 过长(上限 512 字符)".to_string(),
290                ));
291            }
292            Some(a.to_string())
293        }
294    };
295    if description.trim().chars().count() > 200 {
296        return Err(AppError::BadRequest(
297            "描述过长(上限 200 字符)".to_string(),
298        ));
299    }
300    Ok(avatar_url)
301}
302
303/// 归一化 name / url / description 的空白(校验通过后调用)。
304#[cfg(feature = "server")]
305fn trim_fields(name: String, url: String, description: String) -> (String, String, String) {
306    (
307        name.trim().to_string(),
308        url.trim().to_string(),
309        description.trim().to_string(),
310    )
311}
312
313/// 写操作后的统一视图失效:友链 moka 缓存 + `/friends` SSR 缓存 + 全局世代号。
314#[cfg(feature = "server")]
315fn invalidate_friend_links_views() {
316    crate::cache::invalidate_friend_links();
317    crate::ssr_cache::invalidate_ssr_route("/friends");
318    crate::ssr_cache::bump_global_generation();
319}
320
321/// 把 `friend_links` 行解析为 [`FriendLink`]。
322#[cfg(feature = "server")]
323fn row_to_friend_link(row: &tokio_postgres::Row) -> FriendLink {
324    FriendLink {
325        id: row.get("id"),
326        name: row.get("name"),
327        url: row.get("url"),
328        avatar_url: row.get("avatar_url"),
329        description: row.get("description"),
330        sort_order: row.get("sort_order"),
331        is_active: row.get("is_active"),
332        created_at: row.get("created_at"),
333        updated_at: row.get("updated_at"),
334    }
335}
336
337#[cfg(all(test, feature = "server"))]
338mod tests {
339    use super::*;
340
341    /// 断言错误为带指定子串消息的 BadRequest。
342    fn assert_bad_request(err: AppError, needle: &str) {
343        match err {
344            AppError::BadRequest(m) => {
345                assert!(m.contains(needle), "消息应为 {needle:?},实际:{m}")
346            }
347            other => panic!("应为 BadRequest,实际:{other:?}"),
348        }
349    }
350
351    #[test]
352    fn validate_accepts_valid_link() {
353        assert!(validate_link(
354            "示例站",
355            "https://example.com",
356            Some("https://example.com/a.png"),
357            "描述"
358        )
359        .is_ok());
360    }
361
362    #[test]
363    fn validate_accepts_local_asset_avatar() {
364        let avatar = validate_link(
365            "示例站",
366            "https://example.com",
367            Some("/uploads/2026/08/10/avatar.webp"),
368            "",
369        )
370        .expect("本地素材头像应通过友链字段校验");
371        assert_eq!(avatar, Some("/uploads/2026/08/10/avatar.webp".to_string()));
372    }
373
374    #[test]
375    fn validate_rejects_empty_name() {
376        assert_bad_request(
377            validate_link("  ", "https://example.com", None, "").unwrap_err(),
378            "友链名称不能为空",
379        );
380    }
381
382    #[test]
383    fn validate_rejects_overlong_name() {
384        let long = "名".repeat(65);
385        assert_bad_request(
386            validate_link(&long, "https://example.com", None, "").unwrap_err(),
387            "友链名称过长",
388        );
389    }
390
391    #[test]
392    fn validate_rejects_non_http_url() {
393        assert_bad_request(
394            validate_link("示例站", "ftp://example.com", None, "").unwrap_err(),
395            "友链 URL 必须为 http(s) 链接",
396        );
397    }
398
399    #[test]
400    fn validate_normalizes_empty_avatar() {
401        let avatar = validate_link("示例站", "https://example.com", Some("   "), "").unwrap();
402        assert_eq!(avatar, None);
403        let avatar = validate_link("示例站", "https://example.com", None, "").unwrap();
404        assert_eq!(avatar, None);
405    }
406
407    #[test]
408    fn validate_rejects_bad_avatar() {
409        assert_bad_request(
410            validate_link(
411                "示例站",
412                "https://example.com",
413                Some("javascript:alert(1)"),
414                "",
415            )
416            .unwrap_err(),
417            "头像 URL 必须为 http(s) 链接或 /uploads/ 素材路径",
418        );
419    }
420
421    #[test]
422    fn validate_rejects_unsafe_local_avatar() {
423        assert_bad_request(
424            validate_link(
425                "示例站",
426                "https://example.com",
427                Some("/uploads/../secret.png"),
428                "",
429            )
430            .unwrap_err(),
431            "头像 URL 必须为 http(s) 链接或 /uploads/ 素材路径",
432        );
433    }
434
435    #[test]
436    fn validate_rejects_overlong_description() {
437        let long = "描".repeat(201);
438        assert_bad_request(
439            validate_link("示例站", "https://example.com", None, &long).unwrap_err(),
440            "描述过长",
441        );
442    }
443}