yggdrasil/api/comments/
helpers.rs1#![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")]
27pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
28 let email: String = row.get("author_email");
29 let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
30 let created_at_iso = created_at_dt.to_rfc3339();
31 let created_at_relative = format_relative_time(created_at_dt);
32
33 PublicComment {
34 id: row.get("id"),
35 parent_id: row.get("parent_id"),
36 depth: row.get("depth"),
37 author_name: row.get("author_name"),
38 author_url: row.get("author_url"),
39 avatar_url: gravatar_url(&email),
40 content_html: row.get("content_html"),
41 created_at: created_at_relative,
42 created_at_iso,
43 }
44}
45
46#[cfg(feature = "server")]
48pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
49 let status_str: String = row.get("status");
50 let email: String = row.get("author_email");
51
52 AdminComment {
53 id: row.get("id"),
54 post_id: row.get("post_id"),
55 post_title: row.get("post_title"),
56 post_slug: row.get("post_slug"),
57 parent_id: row.get("parent_id"),
58 depth: row.get("depth"),
59 author_name: row.get("author_name"),
60 author_email: email.clone(),
61 author_url: row.get("author_url"),
62 avatar_url: gravatar_url(&email),
63 content_md: row.get("content_md"),
64 status: CommentStatus::from_str(&status_str),
65 created_at: row.get("created_at"),
66 }
67}
68
69#[cfg(feature = "server")]
74pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
75 let now = chrono::Utc::now();
76 let delta_millis = now.signed_duration_since(dt).num_milliseconds();
77 let iso = dt.to_rfc3339();
78 crate::utils::time::relative_label_from_millis(delta_millis, &iso).0
79}
80
81#[cfg(feature = "server")]
83pub fn validate_comment_name(name: &str) -> Result<(), String> {
84 let trimmed = name.trim();
85 if trimmed.is_empty() {
86 return Err("请输入昵称".to_string());
87 }
88 if trimmed.len() > 50 {
89 return Err("昵称长度不能超过 50 个字符".to_string());
90 }
91 Ok(())
92}
93
94#[cfg(feature = "server")]
96pub fn validate_comment_email(email: &str) -> Result<(), String> {
97 if !crate::utils::server::EMAIL_REGEX.is_match(email.trim()) {
98 return Err("邮箱格式不正确".to_string());
99 }
100 Ok(())
101}
102
103#[cfg(feature = "server")]
106pub fn validate_comment_url(url: &str) -> Result<(), String> {
107 let trimmed = url.trim();
108 if trimmed.is_empty() {
109 return Ok(());
110 }
111 let lower = trimmed.to_ascii_lowercase();
112 if !lower.starts_with("http://") && !lower.starts_with("https://") {
113 return Err("网址必须以 http:// 或 https:// 开头".to_string());
114 }
115 if trimmed.len() > 200 {
116 return Err("网址长度不能超过 200 个字符".to_string());
117 }
118 if trimmed
119 .chars()
120 .any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
121 {
122 return Err("网址包含非法字符".to_string());
123 }
124 Ok(())
125}
126
127#[cfg(feature = "server")]
129pub fn validate_comment_content(content: &str) -> Result<(), String> {
130 let trimmed = content.trim();
131 if trimmed.is_empty() {
132 return Err("请输入评论内容".to_string());
133 }
134 if trimmed.len() > 10000 {
135 return Err("评论内容不能超过 10000 个字符".to_string());
136 }
137 Ok(())
138}
139
140#[cfg(feature = "server")]
145pub fn validate_comment_honeypot(value: &str) -> Result<(), String> {
146 if value.is_empty() {
147 Ok(())
148 } else {
149 Err("评论提交异常".to_string())
150 }
151}
152
153#[cfg(feature = "server")]
155pub fn compute_content_hash(
156 post_id: i32,
157 parent_id: Option<i64>,
158 name: &str,
159 content: &str,
160) -> String {
161 use sha2::Digest;
162 let input = format!(
163 "{}:{}:{}:{}",
164 post_id,
165 parent_id.map(|id| id.to_string()).unwrap_or_default(),
166 name.trim(),
167 content.trim()
168 );
169 let hash = sha2::Sha256::digest(input.as_bytes());
170 hex::encode(hash)
171}
172
173#[cfg(all(test, feature = "server"))]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn md5_hash_known_value() {
179 assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
180 }
181
182 #[test]
183 fn md5_hash_empty() {
184 assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
185 }
186
187 #[test]
188 fn gravatar_url_format() {
189 let url = gravatar_url("test@example.com");
190 assert!(url.starts_with("https://cravatar.cn/avatar/"));
191 assert!(url.contains("?d=mp&s=80"));
192 }
193
194 #[test]
195 fn gravatar_url_normalizes_email() {
196 let url1 = gravatar_url("Test@Example.com");
197 let url2 = gravatar_url("test@example.com");
198 assert_eq!(url1, url2);
199 }
200
201 #[test]
202 fn gravatar_url_trims_whitespace() {
203 let url1 = gravatar_url(" test@example.com ");
204 let url2 = gravatar_url("test@example.com");
205 assert_eq!(url1, url2);
206 }
207
208 #[test]
209 fn format_relative_time_just_now() {
210 let now = chrono::Utc::now();
211 assert_eq!(format_relative_time(now), "刚刚");
212 }
213
214 #[test]
215 fn format_relative_time_minutes() {
216 let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
217 assert_eq!(format_relative_time(dt), "5 分钟前");
218 }
219
220 #[test]
221 fn format_relative_time_hours() {
222 let dt = chrono::Utc::now() - chrono::Duration::hours(3);
223 assert_eq!(format_relative_time(dt), "3 小时前");
224 }
225
226 #[test]
227 fn format_relative_time_days() {
228 let dt = chrono::Utc::now() - chrono::Duration::days(7);
229 assert_eq!(format_relative_time(dt), "7 天前");
230 }
231
232 #[test]
233 fn format_relative_time_one_minute() {
234 let dt = chrono::Utc::now() - chrono::Duration::minutes(1);
235 assert_eq!(format_relative_time(dt), "1 分钟前");
236 }
237
238 #[test]
239 fn format_relative_time_one_hour() {
240 let dt = chrono::Utc::now() - chrono::Duration::hours(1);
241 assert_eq!(format_relative_time(dt), "1 小时前");
242 }
243
244 #[test]
245 fn format_relative_time_one_day() {
246 let dt = chrono::Utc::now() - chrono::Duration::days(1);
247 assert_eq!(format_relative_time(dt), "1 天前");
248 }
249
250 #[test]
251 fn format_relative_time_old_date() {
252 let dt = chrono::Utc::now() - chrono::Duration::days(60);
253 let result = format_relative_time(dt);
254 assert!(result.contains('-'));
255 assert_eq!(result.len(), 10);
256 }
257
258 #[test]
259 fn validate_comment_name_valid() {
260 assert!(validate_comment_name("Alice").is_ok());
261 assert!(validate_comment_name("张三").is_ok());
262 }
263
264 #[test]
265 fn validate_comment_name_empty() {
266 assert!(validate_comment_name("").is_err());
267 assert!(validate_comment_name(" ").is_err());
268 }
269
270 #[test]
271 fn validate_comment_name_too_long() {
272 assert!(validate_comment_name(&"a".repeat(51)).is_err());
273 }
274
275 #[test]
276 fn validate_comment_name_max_length() {
277 assert!(validate_comment_name(&"a".repeat(50)).is_ok());
278 }
279
280 #[test]
281 fn validate_comment_email_valid() {
282 assert!(validate_comment_email("user@example.com").is_ok());
283 assert!(validate_comment_email("a.b+c@domain.co").is_ok());
284 }
285
286 #[test]
287 fn validate_comment_email_invalid() {
288 assert!(validate_comment_email("notanemail").is_err());
289 assert!(validate_comment_email("@domain.com").is_err());
290 assert!(validate_comment_email("user@").is_err());
291 }
292
293 #[test]
294 fn validate_comment_url_valid() {
295 assert!(validate_comment_url("http://example.com").is_ok());
296 assert!(validate_comment_url("https://example.com/path").is_ok());
297 }
298
299 #[test]
300 fn validate_comment_url_empty_is_ok() {
301 assert!(validate_comment_url("").is_ok());
302 assert!(validate_comment_url(" ").is_ok());
303 }
304
305 #[test]
306 fn validate_comment_url_invalid_scheme() {
307 assert!(validate_comment_url("ftp://example.com").is_err());
308 assert!(validate_comment_url("javascript:alert(1)").is_err());
309 }
310
311 #[test]
312 fn validate_comment_url_uppercase_scheme() {
313 assert!(validate_comment_url("HTTP://example.com").is_ok());
314 assert!(validate_comment_url("HTTPS://example.com").is_ok());
315 assert!(validate_comment_url("Http://example.com").is_ok());
316 }
317
318 #[test]
319 fn validate_comment_url_fragment() {
320 assert!(validate_comment_url("https://example.com#section").is_ok());
321 }
322
323 #[test]
324 fn validate_comment_url_relative_path_rejected() {
325 assert!(validate_comment_url("/path/to/page").is_err());
326 assert!(validate_comment_url("path/to/page").is_err());
327 }
328
329 #[test]
330 fn validate_comment_url_too_long() {
331 let long_url = format!("https://example.com/{}", "a".repeat(200));
332 assert!(validate_comment_url(&long_url).is_err());
333 }
334
335 #[test]
336 fn validate_comment_content_valid() {
337 assert!(validate_comment_content("Hello world").is_ok());
338 }
339
340 #[test]
341 fn validate_comment_content_empty() {
342 assert!(validate_comment_content("").is_err());
343 assert!(validate_comment_content(" ").is_err());
344 }
345
346 #[test]
347 fn validate_comment_content_too_long() {
348 assert!(validate_comment_content(&"a".repeat(10001)).is_err());
349 }
350
351 #[test]
352 fn validate_comment_content_max_length() {
353 assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
354 }
355
356 #[test]
357 fn compute_content_hash_deterministic() {
358 let h1 = compute_content_hash(1, None, "Alice", "Hello");
359 let h2 = compute_content_hash(1, None, "Alice", "Hello");
360 assert_eq!(h1, h2);
361 }
362
363 #[test]
364 fn compute_content_hash_different_inputs() {
365 let h1 = compute_content_hash(1, None, "Alice", "Hello");
366 let h2 = compute_content_hash(2, None, "Alice", "Hello");
367 assert_ne!(h1, h2);
368 }
369
370 #[test]
371 fn compute_content_hash_trims_whitespace() {
372 let h1 = compute_content_hash(1, None, "Alice", "Hello");
373 let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
374 assert_eq!(h1, h2);
375 }
376
377 #[test]
378 fn compute_content_hash_64_hex_chars() {
379 let h = compute_content_hash(1, None, "Alice", "Hello");
380 assert_eq!(h.len(), 64);
381 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
382 }
383
384 #[test]
385 fn validate_comment_honeypot_empty_is_ok() {
386 assert!(validate_comment_honeypot("").is_ok());
387 }
388
389 #[test]
390 fn validate_comment_honeypot_filled_is_err() {
391 assert!(validate_comment_honeypot("anything").is_err());
392 assert!(validate_comment_honeypot(" ").is_err());
393 }
394}