yggdrasil/src/api/comments/helpers.rs
xfy 690e00e194 refactor(unwrap): 消除非测试代码中的裸 .unwrap(),改为带不变量说明的 .expect()
panic="abort" 下任何 panic 直接杀进程,与 unwrap 的「稍后处理」语义冲突。
本轮把所有可证不变量的裸 unwrap 改写为带说明的 expect,并修复 time.rs 中
u32→i32 转换在大 ms 值下的潜在溢出 panic(clamp 到 i32::MAX)。

- src/utils/time.rs: sleep_ms 的 ms.try_into().unwrap() 改为 clamp 到
  i32::MAX,避免超大延时值溢出;window/set_timeout 的 expect 补充上下文说明
- src/utils/text.rs: 6 个静态正则的 unwrap 改为 expect,统一说明「编译期校验」
- src/api/{auth,comments/helpers}.rs: EMAIL_REGEX 同上
- src/api/markdown.rs: IMG_RE / TABLE_RE 同上
- src/api/rate_limit.rs: NonZeroU32::new(val.max(1)) 与 Quota::with_period
  补充不变量说明
- src/api/image.rs: HeaderValue::from_str(&etag).unwrap() 改为 expect 说明
  etag 仅含 ASCII hex;复用 etag_value 避免 clone
- src/middleware.rs: 静态 302 重定向响应的 unwrap 改为 expect
- src/components/post/post_content.rs、src/theme.rs: WASM 上下文 window().unwrap()
  改为 expect 说明仅浏览器执行

保留的 unwrap/expect(合理不变量,已在注释中说明):
- DB_POOL LazyLock 闭包(已有详细注释 + validate_database_url 前置校验)
- csrf.rs 静态 forbidden 响应
- highlight.rs syntect 内置 Plain Text 语法
- bin/generate_highlight_css 构建工具
- comments/* 的 #[cfg(not(server))] unreachable!() WASM stub
2026-07-23 18:26:19 +08:00

401 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 评论模块的辅助函数:数据转换、校验、哈希与头像生成。
//!
//! 所有工具函数仅在 `feature = "server"` 启用的服务端构建中使用。
#![allow(clippy::unused_unit, deprecated)]
#[cfg(feature = "server")]
use crate::models::comment::{AdminComment, CommentStatus, PublicComment};
/// 计算字符串的 MD5 哈希,用于 Gravatar。
#[cfg(feature = "server")]
pub fn md5_hash(input: &str) -> String {
use md5::Digest;
let hash = md5::Md5::digest(input.as_bytes());
hex::encode(hash)
}
/// 根据邮箱生成 CravatarGravatar 国内镜像)头像 URL。
#[cfg(feature = "server")]
pub fn gravatar_url(email: &str) -> String {
let hash = md5_hash(&email.trim().to_lowercase());
format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash)
}
/// 将数据库行转换为前端展示的公开评论结构。
#[cfg(feature = "server")]
pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
let email: String = row.get("author_email");
let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
let created_at_iso = created_at_dt.to_rfc3339();
let created_at_relative = format_relative_time(created_at_dt);
PublicComment {
id: row.get("id"),
parent_id: row.get("parent_id"),
depth: row.get("depth"),
author_name: row.get("author_name"),
author_url: row.get("author_url"),
avatar_url: gravatar_url(&email),
content_html: row.get("content_html"),
created_at: created_at_relative,
created_at_iso,
}
}
/// 将数据库行转换为后台管理使用的评论结构。
#[cfg(feature = "server")]
pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
let status_str: String = row.get("status");
let email: String = row.get("author_email");
AdminComment {
id: row.get("id"),
post_id: row.get("post_id"),
post_title: row.get("post_title"),
post_slug: row.get("post_slug"),
parent_id: row.get("parent_id"),
depth: row.get("depth"),
author_name: row.get("author_name"),
author_email: email.clone(),
author_url: row.get("author_url"),
avatar_url: gravatar_url(&email),
content_md: row.get("content_md"),
status: CommentStatus::from_str(&status_str),
created_at: row.get("created_at"),
}
}
/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
///
/// 分档规则与前端 `crate::utils::time::relative_label_from_millis` 完全一致,
/// 通过共享分档函数保证服务端预渲染与前端实时计算的口径统一。
#[cfg(feature = "server")]
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let delta_millis = now.signed_duration_since(dt).num_milliseconds();
let iso = dt.to_rfc3339();
crate::utils::time::relative_label_from_millis(delta_millis, &iso).0
}
/// 校验评论作者昵称:非空且不超过 50 字符。
#[cfg(feature = "server")]
pub fn validate_comment_name(name: &str) -> Result<(), String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("请输入昵称".to_string());
}
if trimmed.len() > 50 {
return Err("昵称长度不能超过 50 个字符".to_string());
}
Ok(())
}
#[cfg(feature = "server")]
static EMAIL_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
.expect("EMAIL_REGEX 正则模式应在编译期通过校验")
});
/// 校验评论作者邮箱格式。
#[cfg(feature = "server")]
pub fn validate_comment_email(email: &str) -> Result<(), String> {
if !EMAIL_REGEX.is_match(email.trim()) {
return Err("邮箱格式不正确".to_string());
}
Ok(())
}
/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头、
/// 不含 HTML 特殊字符与空白,且不超过 200 字符。
#[cfg(feature = "server")]
pub fn validate_comment_url(url: &str) -> Result<(), String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Ok(());
}
let lower = trimmed.to_ascii_lowercase();
if !lower.starts_with("http://") && !lower.starts_with("https://") {
return Err("网址必须以 http:// 或 https:// 开头".to_string());
}
if trimmed.len() > 200 {
return Err("网址长度不能超过 200 个字符".to_string());
}
if trimmed
.chars()
.any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
{
return Err("网址包含非法字符".to_string());
}
Ok(())
}
/// 校验评论内容:非空且不超过 10000 字符。
#[cfg(feature = "server")]
pub fn validate_comment_content(content: &str) -> Result<(), String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("请输入评论内容".to_string());
}
if trimmed.len() > 10000 {
return Err("评论内容不能超过 10000 个字符".to_string());
}
Ok(())
}
/// 校验蜜罐字段:正常用户蜜罐为空,机器人可能填入任意内容。
///
/// 为空视为通过;一旦被填入内容即判定为机器人提交并拒绝。
/// 这是禁用 JS / 无视前端校验的机器人也能在服务端被拦下的防线。
#[cfg(feature = "server")]
pub fn validate_comment_honeypot(value: &str) -> Result<(), String> {
if value.is_empty() {
Ok(())
} else {
Err("评论提交异常".to_string())
}
}
/// 计算评论内容哈希,用于检测短时间内的重复提交。
#[cfg(feature = "server")]
pub fn compute_content_hash(
post_id: i32,
parent_id: Option<i64>,
name: &str,
content: &str,
) -> String {
use sha2::Digest;
let input = format!(
"{}:{}:{}:{}",
post_id,
parent_id.map(|id| id.to_string()).unwrap_or_default(),
name.trim(),
content.trim()
);
let hash = sha2::Sha256::digest(input.as_bytes());
hex::encode(hash)
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn md5_hash_known_value() {
assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
}
#[test]
fn md5_hash_empty() {
assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
}
#[test]
fn gravatar_url_format() {
let url = gravatar_url("test@example.com");
assert!(url.starts_with("https://cravatar.cn/avatar/"));
assert!(url.contains("?d=mp&s=80"));
}
#[test]
fn gravatar_url_normalizes_email() {
let url1 = gravatar_url("Test@Example.com");
let url2 = gravatar_url("test@example.com");
assert_eq!(url1, url2);
}
#[test]
fn gravatar_url_trims_whitespace() {
let url1 = gravatar_url(" test@example.com ");
let url2 = gravatar_url("test@example.com");
assert_eq!(url1, url2);
}
#[test]
fn format_relative_time_just_now() {
let now = chrono::Utc::now();
assert_eq!(format_relative_time(now), "刚刚");
}
#[test]
fn format_relative_time_minutes() {
let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
assert_eq!(format_relative_time(dt), "5 分钟前");
}
#[test]
fn format_relative_time_hours() {
let dt = chrono::Utc::now() - chrono::Duration::hours(3);
assert_eq!(format_relative_time(dt), "3 小时前");
}
#[test]
fn format_relative_time_days() {
let dt = chrono::Utc::now() - chrono::Duration::days(7);
assert_eq!(format_relative_time(dt), "7 天前");
}
#[test]
fn format_relative_time_one_minute() {
let dt = chrono::Utc::now() - chrono::Duration::minutes(1);
assert_eq!(format_relative_time(dt), "1 分钟前");
}
#[test]
fn format_relative_time_one_hour() {
let dt = chrono::Utc::now() - chrono::Duration::hours(1);
assert_eq!(format_relative_time(dt), "1 小时前");
}
#[test]
fn format_relative_time_one_day() {
let dt = chrono::Utc::now() - chrono::Duration::days(1);
assert_eq!(format_relative_time(dt), "1 天前");
}
#[test]
fn format_relative_time_old_date() {
let dt = chrono::Utc::now() - chrono::Duration::days(60);
let result = format_relative_time(dt);
assert!(result.contains('-'));
assert_eq!(result.len(), 10);
}
#[test]
fn validate_comment_name_valid() {
assert!(validate_comment_name("Alice").is_ok());
assert!(validate_comment_name("张三").is_ok());
}
#[test]
fn validate_comment_name_empty() {
assert!(validate_comment_name("").is_err());
assert!(validate_comment_name(" ").is_err());
}
#[test]
fn validate_comment_name_too_long() {
assert!(validate_comment_name(&"a".repeat(51)).is_err());
}
#[test]
fn validate_comment_name_max_length() {
assert!(validate_comment_name(&"a".repeat(50)).is_ok());
}
#[test]
fn validate_comment_email_valid() {
assert!(validate_comment_email("user@example.com").is_ok());
assert!(validate_comment_email("a.b+c@domain.co").is_ok());
}
#[test]
fn validate_comment_email_invalid() {
assert!(validate_comment_email("notanemail").is_err());
assert!(validate_comment_email("@domain.com").is_err());
assert!(validate_comment_email("user@").is_err());
}
#[test]
fn validate_comment_url_valid() {
assert!(validate_comment_url("http://example.com").is_ok());
assert!(validate_comment_url("https://example.com/path").is_ok());
}
#[test]
fn validate_comment_url_empty_is_ok() {
assert!(validate_comment_url("").is_ok());
assert!(validate_comment_url(" ").is_ok());
}
#[test]
fn validate_comment_url_invalid_scheme() {
assert!(validate_comment_url("ftp://example.com").is_err());
assert!(validate_comment_url("javascript:alert(1)").is_err());
}
#[test]
fn validate_comment_url_uppercase_scheme() {
assert!(validate_comment_url("HTTP://example.com").is_ok());
assert!(validate_comment_url("HTTPS://example.com").is_ok());
assert!(validate_comment_url("Http://example.com").is_ok());
}
#[test]
fn validate_comment_url_fragment() {
assert!(validate_comment_url("https://example.com#section").is_ok());
}
#[test]
fn validate_comment_url_relative_path_rejected() {
assert!(validate_comment_url("/path/to/page").is_err());
assert!(validate_comment_url("path/to/page").is_err());
}
#[test]
fn validate_comment_url_too_long() {
let long_url = format!("https://example.com/{}", "a".repeat(200));
assert!(validate_comment_url(&long_url).is_err());
}
#[test]
fn validate_comment_content_valid() {
assert!(validate_comment_content("Hello world").is_ok());
}
#[test]
fn validate_comment_content_empty() {
assert!(validate_comment_content("").is_err());
assert!(validate_comment_content(" ").is_err());
}
#[test]
fn validate_comment_content_too_long() {
assert!(validate_comment_content(&"a".repeat(10001)).is_err());
}
#[test]
fn validate_comment_content_max_length() {
assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
}
#[test]
fn compute_content_hash_deterministic() {
let h1 = compute_content_hash(1, None, "Alice", "Hello");
let h2 = compute_content_hash(1, None, "Alice", "Hello");
assert_eq!(h1, h2);
}
#[test]
fn compute_content_hash_different_inputs() {
let h1 = compute_content_hash(1, None, "Alice", "Hello");
let h2 = compute_content_hash(2, None, "Alice", "Hello");
assert_ne!(h1, h2);
}
#[test]
fn compute_content_hash_trims_whitespace() {
let h1 = compute_content_hash(1, None, "Alice", "Hello");
let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
assert_eq!(h1, h2);
}
#[test]
fn compute_content_hash_64_hex_chars() {
let h = compute_content_hash(1, None, "Alice", "Hello");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn validate_comment_honeypot_empty_is_ok() {
assert!(validate_comment_honeypot("").is_ok());
}
#[test]
fn validate_comment_honeypot_filled_is_err() {
assert!(validate_comment_honeypot("anything").is_err());
assert!(validate_comment_honeypot(" ").is_err());
}
}