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