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")]
206fn validate_link(
207 name: &str,
208 url: &str,
209 avatar_url: Option<&str>,
210 description: &str,
211) -> Result<Option<String>, AppError> {
212 if name.trim().is_empty() {
213 return Err(AppError::BadRequest("友链名称不能为空".to_string()));
214 }
215 if name.trim().chars().count() > 64 {
216 return Err(AppError::BadRequest(
217 "友链名称过长(上限 64 字符)".to_string(),
218 ));
219 }
220 let url = url.trim();
221 if !(url.starts_with("http://") || url.starts_with("https://")) {
222 return Err(AppError::BadRequest(
223 "友链 URL 必须为 http(s) 链接".to_string(),
224 ));
225 }
226 if url.chars().count() > 512 {
227 return Err(AppError::BadRequest(
228 "URL 过长(上限 512 字符)".to_string(),
229 ));
230 }
231 let avatar_url = match avatar_url.map(str::trim) {
232 None | Some("") => None,
233 Some(a) => {
234 if !(a.starts_with("http://") || a.starts_with("https://")) {
235 return Err(AppError::BadRequest(
236 "头像 URL 必须为 http(s) 链接".to_string(),
237 ));
238 }
239 if a.chars().count() > 512 {
240 return Err(AppError::BadRequest(
241 "头像 URL 过长(上限 512 字符)".to_string(),
242 ));
243 }
244 Some(a.to_string())
245 }
246 };
247 if description.trim().chars().count() > 200 {
248 return Err(AppError::BadRequest(
249 "描述过长(上限 200 字符)".to_string(),
250 ));
251 }
252 Ok(avatar_url)
253}
254
255#[cfg(feature = "server")]
257fn trim_fields(name: String, url: String, description: String) -> (String, String, String) {
258 (
259 name.trim().to_string(),
260 url.trim().to_string(),
261 description.trim().to_string(),
262 )
263}
264
265#[cfg(feature = "server")]
267fn invalidate_friend_links_views() {
268 crate::cache::invalidate_friend_links();
269 crate::ssr_cache::invalidate_ssr_route("/friends");
270 crate::ssr_cache::bump_global_generation();
271}
272
273#[cfg(feature = "server")]
275fn row_to_friend_link(row: &tokio_postgres::Row) -> FriendLink {
276 FriendLink {
277 id: row.get("id"),
278 name: row.get("name"),
279 url: row.get("url"),
280 avatar_url: row.get("avatar_url"),
281 description: row.get("description"),
282 sort_order: row.get("sort_order"),
283 is_active: row.get("is_active"),
284 created_at: row.get("created_at"),
285 updated_at: row.get("updated_at"),
286 }
287}
288
289#[cfg(all(test, feature = "server"))]
290mod tests {
291 use super::*;
292
293 fn assert_bad_request(err: AppError, needle: &str) {
295 match err {
296 AppError::BadRequest(m) => {
297 assert!(m.contains(needle), "消息应为 {needle:?},实际:{m}")
298 }
299 other => panic!("应为 BadRequest,实际:{other:?}"),
300 }
301 }
302
303 #[test]
304 fn validate_accepts_valid_link() {
305 assert!(validate_link(
306 "示例站",
307 "https://example.com",
308 Some("https://example.com/a.png"),
309 "描述"
310 )
311 .is_ok());
312 }
313
314 #[test]
315 fn validate_rejects_empty_name() {
316 assert_bad_request(
317 validate_link(" ", "https://example.com", None, "").unwrap_err(),
318 "友链名称不能为空",
319 );
320 }
321
322 #[test]
323 fn validate_rejects_overlong_name() {
324 let long = "名".repeat(65);
325 assert_bad_request(
326 validate_link(&long, "https://example.com", None, "").unwrap_err(),
327 "友链名称过长",
328 );
329 }
330
331 #[test]
332 fn validate_rejects_non_http_url() {
333 assert_bad_request(
334 validate_link("示例站", "ftp://example.com", None, "").unwrap_err(),
335 "友链 URL 必须为 http(s) 链接",
336 );
337 }
338
339 #[test]
340 fn validate_normalizes_empty_avatar() {
341 let avatar = validate_link("示例站", "https://example.com", Some(" "), "").unwrap();
342 assert_eq!(avatar, None);
343 let avatar = validate_link("示例站", "https://example.com", None, "").unwrap();
344 assert_eq!(avatar, None);
345 }
346
347 #[test]
348 fn validate_rejects_bad_avatar() {
349 assert_bad_request(
350 validate_link(
351 "示例站",
352 "https://example.com",
353 Some("javascript:alert(1)"),
354 "",
355 )
356 .unwrap_err(),
357 "头像 URL 必须为 http(s) 链接",
358 );
359 }
360
361 #[test]
362 fn validate_rejects_overlong_description() {
363 let long = "描".repeat(201);
364 assert_bad_request(
365 validate_link("示例站", "https://example.com", None, &long).unwrap_err(),
366 "描述过长",
367 );
368 }
369}