Skip to main content

yggdrasil/api/
friends.rs

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