1#![allow(clippy::unused_unit, deprecated)]
6
7#[cfg(feature = "server")]
8use crate::models::comment::{AdminComment, CommentStatus, PublicComment};
9
10#[cfg(feature = "server")]
12pub fn md5_hash(input: &str) -> String {
13 use md5::Digest;
14 let hash = md5::Md5::digest(input.as_bytes());
15 hex::encode(hash)
16}
17
18#[cfg(feature = "server")]
20pub fn gravatar_url(email: &str) -> String {
21 let hash = md5_hash(&email.trim().to_lowercase());
22 format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash)
23}
24
25#[cfg(feature = "server")]
32pub fn resolve_author_display(
33 author_name: &str,
34 author_email: &str,
35 user_id: Option<i32>,
36 user_display_name: Option<&str>,
37 user_avatar_url: Option<&str>,
38) -> (String, String, bool) {
39 let is_author = user_id.is_some();
40 let name = user_display_name
41 .map(str::trim)
42 .filter(|s| !s.is_empty())
43 .map(crate::utils::html::escape_html)
44 .unwrap_or_else(|| author_name.to_string());
45 let avatar = user_avatar_url
46 .map(str::trim)
47 .filter(|s| !s.is_empty())
48 .map(str::to_string)
49 .unwrap_or_else(|| gravatar_url(author_email));
50 (name, avatar, is_author)
51}
52
53#[cfg(feature = "server")]
58pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
59 let email: String = row.get("author_email");
60 let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
61 let created_at_iso = created_at_dt.to_rfc3339();
62 let created_at_relative = format_relative_time(created_at_dt);
63
64 let snapshot_name: String = row.get("author_name");
65 let user_id: Option<i32> = row.get("user_id");
66 let user_display_name: Option<String> = row.get("user_display_name");
67 let user_avatar: Option<String> = row.get("user_avatar");
68 let (author_name, avatar_url, is_author) = resolve_author_display(
69 &snapshot_name,
70 &email,
71 user_id,
72 user_display_name.as_deref(),
73 user_avatar.as_deref(),
74 );
75
76 PublicComment {
77 id: row.get("id"),
78 parent_id: row.get("parent_id"),
79 depth: row.get("depth"),
80 author_name,
81 author_url: row.get("author_url"),
82 avatar_url,
83 is_author,
84 content_html: row.get("content_html"),
85 created_at: created_at_relative,
86 created_at_iso,
87 }
88}
89
90#[cfg(feature = "server")]
95pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
96 let status_str: String = row.get("status");
97 let email: String = row.get("author_email");
98
99 let snapshot_name: String = row.get("author_name");
100 let user_id: Option<i32> = row.get("user_id");
101 let user_display_name: Option<String> = row.get("user_display_name");
102 let user_avatar: Option<String> = row.get("user_avatar");
103 let (author_name, avatar_url, _) = resolve_author_display(
104 &snapshot_name,
105 &email,
106 user_id,
107 user_display_name.as_deref(),
108 user_avatar.as_deref(),
109 );
110
111 AdminComment {
112 id: row.get("id"),
113 post_id: row.get("post_id"),
114 post_title: row.get("post_title"),
115 post_slug: row.get("post_slug"),
116 parent_id: row.get("parent_id"),
117 depth: row.get("depth"),
118 author_name,
119 author_email: email.clone(),
120 author_url: row.get("author_url"),
121 avatar_url,
122 content_md: row.get("content_md"),
123 status: CommentStatus::from_str(&status_str),
124 created_at: row.get("created_at"),
125 }
126}
127
128#[cfg(feature = "server")]
133pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
134 let now = chrono::Utc::now();
135 let delta_millis = now.signed_duration_since(dt).num_milliseconds();
136 let iso = dt.to_rfc3339();
137 crate::utils::time::relative_label_from_millis(delta_millis, &iso).0
138}
139
140#[cfg(feature = "server")]
142pub fn validate_comment_name(name: &str) -> Result<(), String> {
143 let trimmed = name.trim();
144 if trimmed.is_empty() {
145 return Err("请输入昵称".to_string());
146 }
147 if trimmed.len() > 50 {
148 return Err("昵称长度不能超过 50 个字符".to_string());
149 }
150 Ok(())
151}
152
153#[cfg(feature = "server")]
155pub fn validate_comment_email(email: &str) -> Result<(), String> {
156 if !crate::utils::server::EMAIL_REGEX.is_match(email.trim()) {
157 return Err("邮箱格式不正确".to_string());
158 }
159 Ok(())
160}
161
162#[cfg(feature = "server")]
165pub fn validate_comment_url(url: &str) -> Result<(), String> {
166 let trimmed = url.trim();
167 if trimmed.is_empty() {
168 return Ok(());
169 }
170 let lower = trimmed.to_ascii_lowercase();
171 if !lower.starts_with("http://") && !lower.starts_with("https://") {
172 return Err("网址必须以 http:// 或 https:// 开头".to_string());
173 }
174 if trimmed.len() > 200 {
175 return Err("网址长度不能超过 200 个字符".to_string());
176 }
177 if trimmed
178 .chars()
179 .any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
180 {
181 return Err("网址包含非法字符".to_string());
182 }
183 Ok(())
184}
185
186#[cfg(feature = "server")]
188pub fn validate_comment_content(content: &str) -> Result<(), String> {
189 let trimmed = content.trim();
190 if trimmed.is_empty() {
191 return Err("请输入评论内容".to_string());
192 }
193 if trimmed.len() > 10000 {
194 return Err("评论内容不能超过 10000 个字符".to_string());
195 }
196 Ok(())
197}
198
199#[cfg(feature = "server")]
204pub fn validate_comment_honeypot(value: &str) -> Result<(), String> {
205 if value.is_empty() {
206 Ok(())
207 } else {
208 Err("评论提交异常".to_string())
209 }
210}
211
212#[cfg(feature = "server")]
214pub fn compute_content_hash(
215 post_id: i32,
216 parent_id: Option<i64>,
217 name: &str,
218 content: &str,
219) -> String {
220 use sha2::Digest;
221 let input = format!(
222 "{}:{}:{}:{}",
223 post_id,
224 parent_id.map(|id| id.to_string()).unwrap_or_default(),
225 name.trim(),
226 content.trim()
227 );
228 let hash = sha2::Sha256::digest(input.as_bytes());
229 hex::encode(hash)
230}
231
232#[cfg(all(test, feature = "server"))]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn md5_hash_known_value() {
238 assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
239 }
240
241 #[test]
242 fn md5_hash_empty() {
243 assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
244 }
245
246 #[test]
247 fn gravatar_url_format() {
248 let url = gravatar_url("test@example.com");
249 assert!(url.starts_with("https://cravatar.cn/avatar/"));
250 assert!(url.contains("?d=mp&s=80"));
251 }
252
253 #[test]
254 fn gravatar_url_normalizes_email() {
255 let url1 = gravatar_url("Test@Example.com");
256 let url2 = gravatar_url("test@example.com");
257 assert_eq!(url1, url2);
258 }
259
260 #[test]
261 fn gravatar_url_trims_whitespace() {
262 let url1 = gravatar_url(" test@example.com ");
263 let url2 = gravatar_url("test@example.com");
264 assert_eq!(url1, url2);
265 }
266
267 #[test]
268 fn resolve_author_display_anonymous_uses_snapshot_and_gravatar() {
269 let (name, avatar, is_author) =
270 resolve_author_display("Alice", "a@example.com", None, None, None);
271 assert_eq!(name, "Alice");
272 assert_eq!(avatar, gravatar_url("a@example.com"));
273 assert!(!is_author);
274 }
275
276 #[test]
277 fn resolve_author_display_user_with_profile_fields() {
278 let (name, avatar, is_author) = resolve_author_display(
279 "snapshot",
280 "a@example.com",
281 Some(1),
282 Some("站长"),
283 Some("/uploads/a.webp"),
284 );
285 assert_eq!(name, "站长");
286 assert_eq!(avatar, "/uploads/a.webp");
287 assert!(is_author);
288 }
289
290 #[test]
291 fn resolve_author_display_user_without_display_name_falls_back_to_snapshot() {
292 let (name, avatar, is_author) =
294 resolve_author_display("admin", "a@example.com", Some(1), Some(" "), None);
295 assert_eq!(name, "admin");
296 assert_eq!(avatar, gravatar_url("a@example.com"));
297 assert!(is_author);
298 }
299
300 #[test]
301 fn resolve_author_display_escapes_live_display_name() {
302 let (name, _, _) =
304 resolve_author_display("snapshot", "a@example.com", Some(1), Some("<b>x</b>"), None);
305 assert!(!name.contains('<'));
306 }
307
308 #[test]
309 fn format_relative_time_just_now() {
310 let now = chrono::Utc::now();
311 assert_eq!(format_relative_time(now), "刚刚");
312 }
313
314 #[test]
315 fn format_relative_time_minutes() {
316 let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
317 assert_eq!(format_relative_time(dt), "5 分钟前");
318 }
319
320 #[test]
321 fn format_relative_time_hours() {
322 let dt = chrono::Utc::now() - chrono::Duration::hours(3);
323 assert_eq!(format_relative_time(dt), "3 小时前");
324 }
325
326 #[test]
327 fn format_relative_time_days() {
328 let dt = chrono::Utc::now() - chrono::Duration::days(7);
329 assert_eq!(format_relative_time(dt), "7 天前");
330 }
331
332 #[test]
333 fn format_relative_time_one_minute() {
334 let dt = chrono::Utc::now() - chrono::Duration::minutes(1);
335 assert_eq!(format_relative_time(dt), "1 分钟前");
336 }
337
338 #[test]
339 fn format_relative_time_one_hour() {
340 let dt = chrono::Utc::now() - chrono::Duration::hours(1);
341 assert_eq!(format_relative_time(dt), "1 小时前");
342 }
343
344 #[test]
345 fn format_relative_time_one_day() {
346 let dt = chrono::Utc::now() - chrono::Duration::days(1);
347 assert_eq!(format_relative_time(dt), "1 天前");
348 }
349
350 #[test]
351 fn format_relative_time_old_date() {
352 let dt = chrono::Utc::now() - chrono::Duration::days(60);
353 let result = format_relative_time(dt);
354 assert!(result.contains('-'));
355 assert_eq!(result.len(), 10);
356 }
357
358 #[test]
359 fn validate_comment_name_valid() {
360 assert!(validate_comment_name("Alice").is_ok());
361 assert!(validate_comment_name("张三").is_ok());
362 }
363
364 #[test]
365 fn validate_comment_name_empty() {
366 assert!(validate_comment_name("").is_err());
367 assert!(validate_comment_name(" ").is_err());
368 }
369
370 #[test]
371 fn validate_comment_name_too_long() {
372 assert!(validate_comment_name(&"a".repeat(51)).is_err());
373 }
374
375 #[test]
376 fn validate_comment_name_max_length() {
377 assert!(validate_comment_name(&"a".repeat(50)).is_ok());
378 }
379
380 #[test]
381 fn validate_comment_email_valid() {
382 assert!(validate_comment_email("user@example.com").is_ok());
383 assert!(validate_comment_email("a.b+c@domain.co").is_ok());
384 }
385
386 #[test]
387 fn validate_comment_email_invalid() {
388 assert!(validate_comment_email("notanemail").is_err());
389 assert!(validate_comment_email("@domain.com").is_err());
390 assert!(validate_comment_email("user@").is_err());
391 }
392
393 #[test]
394 fn validate_comment_url_valid() {
395 assert!(validate_comment_url("http://example.com").is_ok());
396 assert!(validate_comment_url("https://example.com/path").is_ok());
397 }
398
399 #[test]
400 fn validate_comment_url_empty_is_ok() {
401 assert!(validate_comment_url("").is_ok());
402 assert!(validate_comment_url(" ").is_ok());
403 }
404
405 #[test]
406 fn validate_comment_url_invalid_scheme() {
407 assert!(validate_comment_url("ftp://example.com").is_err());
408 assert!(validate_comment_url("javascript:alert(1)").is_err());
409 }
410
411 #[test]
412 fn validate_comment_url_uppercase_scheme() {
413 assert!(validate_comment_url("HTTP://example.com").is_ok());
414 assert!(validate_comment_url("HTTPS://example.com").is_ok());
415 assert!(validate_comment_url("Http://example.com").is_ok());
416 }
417
418 #[test]
419 fn validate_comment_url_fragment() {
420 assert!(validate_comment_url("https://example.com#section").is_ok());
421 }
422
423 #[test]
424 fn validate_comment_url_relative_path_rejected() {
425 assert!(validate_comment_url("/path/to/page").is_err());
426 assert!(validate_comment_url("path/to/page").is_err());
427 }
428
429 #[test]
430 fn validate_comment_url_too_long() {
431 let long_url = format!("https://example.com/{}", "a".repeat(200));
432 assert!(validate_comment_url(&long_url).is_err());
433 }
434
435 #[test]
436 fn validate_comment_content_valid() {
437 assert!(validate_comment_content("Hello world").is_ok());
438 }
439
440 #[test]
441 fn validate_comment_content_empty() {
442 assert!(validate_comment_content("").is_err());
443 assert!(validate_comment_content(" ").is_err());
444 }
445
446 #[test]
447 fn validate_comment_content_too_long() {
448 assert!(validate_comment_content(&"a".repeat(10001)).is_err());
449 }
450
451 #[test]
452 fn validate_comment_content_max_length() {
453 assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
454 }
455
456 #[test]
457 fn compute_content_hash_deterministic() {
458 let h1 = compute_content_hash(1, None, "Alice", "Hello");
459 let h2 = compute_content_hash(1, None, "Alice", "Hello");
460 assert_eq!(h1, h2);
461 }
462
463 #[test]
464 fn compute_content_hash_different_inputs() {
465 let h1 = compute_content_hash(1, None, "Alice", "Hello");
466 let h2 = compute_content_hash(2, None, "Alice", "Hello");
467 assert_ne!(h1, h2);
468 }
469
470 #[test]
471 fn compute_content_hash_trims_whitespace() {
472 let h1 = compute_content_hash(1, None, "Alice", "Hello");
473 let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
474 assert_eq!(h1, h2);
475 }
476
477 #[test]
478 fn compute_content_hash_64_hex_chars() {
479 let h = compute_content_hash(1, None, "Alice", "Hello");
480 assert_eq!(h.len(), 64);
481 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
482 }
483
484 #[test]
485 fn validate_comment_honeypot_empty_is_ok() {
486 assert!(validate_comment_honeypot("").is_ok());
487 }
488
489 #[test]
490 fn validate_comment_honeypot_filled_is_err() {
491 assert!(validate_comment_honeypot("anything").is_err());
492 assert!(validate_comment_honeypot(" ").is_err());
493 }
494}