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