1#![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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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}