1use dioxus::prelude::*;
11
12use crate::models::friend_link::FriendLink;
13
14#[cfg(feature = "server")]
15use crate::api::error::AppError;
16
17#[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#[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#[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#[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#[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#[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#[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#[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#[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 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}