diff --git a/src/api/comments/create.rs b/src/api/comments/create.rs index 6f9300d..f836896 100644 --- a/src/api/comments/create.rs +++ b/src/api/comments/create.rs @@ -212,10 +212,10 @@ pub async fn create_comment( // 在开事务前完成纯计算(Markdown 渲染、字段转义、IP/UA 提取),避免 // 在事务持锁期间做无谓工作,缩短关键排他锁窗口。 let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md); - let author_name_safe = crate::api::comments::helpers::escape_html(author_name.trim()); + let author_name_safe = crate::utils::html::escape_html(author_name.trim()); let author_url_safe = author_url .as_ref() - .map(|u| crate::api::comments::helpers::escape_html(u.trim())) + .map(|u| crate::utils::html::escape_html(u.trim())) .filter(|u| !u.is_empty()); let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { let parts = ctx.parts_mut(); diff --git a/src/api/comments/helpers.rs b/src/api/comments/helpers.rs index 601bf3a..2bfcc76 100644 --- a/src/api/comments/helpers.rs +++ b/src/api/comments/helpers.rs @@ -15,17 +15,6 @@ pub fn md5_hash(input: &str) -> String { hex::encode(hash) } -/// 对用于 HTML 展示的文本做基础转义,防止 XSS。 -#[cfg(feature = "server")] -pub fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - /// 根据邮箱生成 Cravatar(Gravatar 国内镜像)头像 URL。 #[cfg(feature = "server")] pub fn gravatar_url(email: &str) -> String { @@ -79,14 +68,14 @@ pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment { /// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 /// -/// 分档规则与前端 `crate::hooks::comment_storage::relative_label_from_millis` 完全一致, +/// 分档规则与前端 `crate::utils::time::relative_label_from_millis` 完全一致, /// 通过共享分档函数保证服务端预渲染与前端实时计算的口径统一。 #[cfg(feature = "server")] pub fn format_relative_time(dt: chrono::DateTime) -> String { let now = chrono::Utc::now(); let delta_millis = now.signed_duration_since(dt).num_milliseconds(); let iso = dt.to_rfc3339(); - crate::hooks::comment_storage::relative_label_from_millis(delta_millis, &iso).0 + crate::utils::time::relative_label_from_millis(delta_millis, &iso).0 } /// 校验评论作者昵称:非空且不超过 50 字符。 @@ -190,18 +179,6 @@ pub fn compute_content_hash( mod tests { use super::*; - #[test] - fn escape_html_escapes_special_chars() { - assert_eq!( - escape_html(""), - "<script>alert(1)</script>" - ); - assert_eq!( - escape_html("\"quoted' & ampersand"), - ""quoted' & ampersand" - ); - } - #[test] fn md5_hash_known_value() { assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592"); diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 36653f3..2f6fce3 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -15,11 +15,11 @@ pub fn clean_html(input: &str) -> String { #[cfg(feature = "server")] /// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `` 正文。 /// -/// 复用 `hooks::comment_storage::escape_html`(转义 `& < > " '`),避免在仓库内 +/// 复用 `utils::html::escape_html`(转义 `& < > " '`),避免在仓库内 /// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如 /// `" onmouseover="alert(1)` 会越出属性边界。 fn escape_heading_text(s: &str) -> String { - crate::hooks::comment_storage::escape_html(s) + crate::utils::html::escape_html(s) } #[derive(Debug, Clone)] diff --git a/src/components/comments/pending_item.rs b/src/components/comments/pending_item.rs index 24cfe43..dadb003 100644 --- a/src/components/comments/pending_item.rs +++ b/src/components/comments/pending_item.rs @@ -5,9 +5,8 @@ use dioxus::prelude::*; -use crate::hooks::comment_storage::{ - format_relative_time_iso, render_pending_content, PendingComment, -}; +use crate::hooks::comment_storage::{render_pending_content, PendingComment}; +use crate::utils::time::format_relative_time_iso; /// 待审核评论项组件。 /// diff --git a/src/hooks/comment_storage.rs b/src/hooks/comment_storage.rs index 3d29fe8..534ec3e 100644 --- a/src/hooks/comment_storage.rs +++ b/src/hooks/comment_storage.rs @@ -85,26 +85,12 @@ fn write_storage(key: &str, value: &str) { } } -/// 获取当前时间戳(毫秒)。 -/// -/// WASM 端使用 `js_sys::Date`,服务端回退到 `chrono::Utc`。 -fn now_millis() -> i64 { - #[cfg(target_arch = "wasm32")] - { - js_sys::Date::now() as i64 - } - #[cfg(not(target_arch = "wasm32"))] - { - chrono::Utc::now().timestamp_millis() - } -} - /// 判断给定存储时间是否已经过期。 fn is_expired(stored_at: &str) -> bool { let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else { return true; }; - let now_ms = now_millis(); + let now_ms = crate::utils::time::now_millis(); let stored_ms = dt.timestamp_millis(); (now_ms - stored_ms) > (TTL_DAYS * 24 * 60 * 60 * 1000) } @@ -235,148 +221,8 @@ fn load_all_pending() -> PendingMap { serde_json::from_str(&json).unwrap_or_default() } -/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 YYYY-MM-DD)。 -/// -/// 分档规则与服务端 `format_relative_time` 完全一致,前端在展示待审核评论时复用, -/// 保证两类评论的时间展示口径统一。返回绝对日期用于 `title` 悬浮提示。 -/// -/// - `delta_millis`:目标时间与"现在"的差值(毫秒)。正值表示过去,负值表示未来(兜底按刚刚处理)。 -/// - `created_iso`:评论的 RFC3339 创建时间,用于兜底生成绝对日期。 -pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (String, String) { - let seconds = delta_millis / 1000; - - let label = if seconds < 60 { - "刚刚".to_string() - } else { - let minutes = seconds / 60; - if minutes < 60 { - format!("{} 分钟前", minutes) - } else { - let hours = minutes / 60; - if hours < 24 { - format!("{} 小时前", hours) - } else { - let days = hours / 24; - if days < 30 { - format!("{} 天前", days) - } else { - // 超过 30 天直接显示日期,下方 absolute 复用 - String::new() - } - } - } - }; - - // 绝对日期:优先解析 ISO;解析失败时退化为空串,避免组件报错。 - let absolute = DateTime::parse_from_rfc3339(created_iso) - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_default(); - - let label = if label.is_empty() { - absolute.clone() - } else { - label - }; - (label, absolute) -} - -/// 前端友好的相对时间格式化:返回相对文本,用于展示待审核评论的创建时间。 -/// -/// 这是 `relative_label_from_millis` 的薄封装,仅返回相对文本。 -pub fn format_relative_time_iso(created_iso: &str) -> String { - // 解析失败时退化为 "刚刚",避免组件崩溃。 - let Ok(dt) = DateTime::parse_from_rfc3339(created_iso) else { - return "刚刚".to_string(); - }; - let delta_millis = now_millis() - dt.timestamp_millis(); - relative_label_from_millis(delta_millis, created_iso).0 -} - -/// HTML 转义辅助函数。 -pub(crate) fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - /// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `
`)。 pub fn render_pending_content(md: &str) -> String { - let escaped = escape_html(md); + let escaped = crate::utils::html::escape_html(md); escaped.replace('\n', "
") } - -#[cfg(test)] -mod tests { - use super::*; - - const ISO: &str = "2026-06-22T05:43:57.565+00:00"; - - #[test] - fn relative_label_just_now_under_60s() { - let (label, _) = relative_label_from_millis(0, ISO); - assert_eq!(label, "刚刚"); - let (label, _) = relative_label_from_millis(59_999, ISO); - assert_eq!(label, "刚刚"); - } - - #[test] - fn relative_label_minutes() { - let (label, _) = relative_label_from_millis(60_000, ISO); - assert_eq!(label, "1 分钟前"); - let (label, _) = relative_label_from_millis(5 * 60_000, ISO); - assert_eq!(label, "5 分钟前"); - let (label, _) = relative_label_from_millis(59 * 60_000, ISO); - assert_eq!(label, "59 分钟前"); - } - - #[test] - fn relative_label_hours() { - let (label, _) = relative_label_from_millis(60 * 60_000, ISO); - assert_eq!(label, "1 小时前"); - let (label, _) = relative_label_from_millis(3 * 3_600_000, ISO); - assert_eq!(label, "3 小时前"); - let (label, _) = relative_label_from_millis(23 * 3_600_000, ISO); - assert_eq!(label, "23 小时前"); - } - - #[test] - fn relative_label_days() { - let (label, _) = relative_label_from_millis(24 * 3_600_000, ISO); - assert_eq!(label, "1 天前"); - let (label, _) = relative_label_from_millis(7 * 24 * 3_600_000, ISO); - assert_eq!(label, "7 天前"); - let (label, _) = relative_label_from_millis(29 * 24 * 3_600_000, ISO); - assert_eq!(label, "29 天前"); - } - - #[test] - fn relative_label_falls_back_to_date_over_30_days() { - let (label, absolute) = relative_label_from_millis(60 * 24 * 3_600_000, ISO); - assert_eq!(label, "2026-06-22"); - assert_eq!(absolute, "2026-06-22"); - } - - #[test] - fn relative_label_future_falls_back_to_just_now() { - // 未来时间差为负,秒数 < 60,归为"刚刚"。 - let (label, _) = relative_label_from_millis(-5_000, ISO); - assert_eq!(label, "刚刚"); - } - - #[test] - fn relative_label_invalid_iso_still_returns_absolute_empty() { - // 无法解析时 absolute 为空,但分档逻辑仍按 delta 决定。 - let (label, absolute) = relative_label_from_millis(0, "not-a-date"); - assert_eq!(label, "刚刚"); - assert_eq!(absolute, ""); - } - - #[test] - fn format_relative_time_iso_invalid_iso_falls_back() { - // 解析失败退化为"刚刚",不 panic。 - assert_eq!(format_relative_time_iso("not-a-date"), "刚刚"); - } -} diff --git a/src/utils/html.rs b/src/utils/html.rs new file mode 100644 index 0000000..b8765f3 --- /dev/null +++ b/src/utils/html.rs @@ -0,0 +1,45 @@ +//! HTML 转义工具(零依赖纯函数,前端后端通用)。 +//! +//! 仓库内原先存在两份 `escape_html` 实现: +//! - `hooks::comment_storage::escape_html`(`'` → `'`) +//! - `api::comments::helpers::escape_html`(`'` → `'`,server-only) +//! 现统一到本模块,单引号采用 HTML5 标准的 `'`。 + +/// 转义 HTML 特殊字符:`& < > " '`。 +/// +/// 单引号统一为 `'`(HTML5 规范,与原 server 端 `helpers::escape_html` 一致)。 +/// 可安全用于文本节点与属性值上下文。 +pub fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escapes_all_five_special_chars() { + assert_eq!(escape_html("&<>\"'"), "&<>"'"); + } + + #[test] + fn escapes_ampersand_first_to_avoid_double_escape() { + // & 必须先转义,否则后续引入的 & 会被再次处理。 + assert_eq!(escape_html("<&>"), "<&>"); + } + + #[test] + fn leaves_plain_text_untouched() { + assert_eq!(escape_html("hello world"), "hello world"); + } + + #[test] + fn empty_input_returns_empty() { + assert_eq!(escape_html(""), ""); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 90cd467..e23dac1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,9 +1,13 @@ //! 通用工具函数子模块。 //! -//! `text` 模块仅在 `server` feature 启用时编译;`time` 模块同时提供 WASM 与原生异步版本。 +//! - `html`:HTML 转义(两端通用)。 +//! - `text`:Markdown / 纯文本处理(仅 `server` feature)。 +//! - `time`:跨平台时间/睡眠工具(WASM 与原生异步版本)。 +/// HTML 转义工具(前端后端通用)。 +pub mod html; /// Markdown / 纯文本处理工具。 #[cfg(feature = "server")] pub mod text; -/// 跨平台的异步睡眠等时间工具。 +/// 跨平台时间/睡眠工具。 pub mod time; diff --git a/src/utils/time.rs b/src/utils/time.rs index 2de5acb..3fc3aaf 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -1,8 +1,13 @@ //! 跨平台时间/睡眠工具。 //! //! 根据目标架构分别实现: -//! - `wasm32`:通过 `js_sys` 调用 JavaScript 的 `setTimeout`。 -//! - 其他平台:使用 `tokio::time::sleep`。 +//! - `wasm32`:通过 `js_sys` 调用 JavaScript 的 `setTimeout` / `Date.now()`。 +//! - 其他平台:使用 `tokio::time::sleep` / `chrono::Utc`。 +//! +//! 相对时间分档(`relative_label_from_millis` / `format_relative_time_iso`)由 +//! 前端待审核评论展示与服务端评论预渲染共享,保证两端口径一致。 + +use chrono::DateTime; /// 异步睡眠指定毫秒数。 #[cfg(target_arch = "wasm32")] @@ -21,3 +26,147 @@ pub async fn sleep_ms(ms: u32) { pub async fn sleep_ms(ms: u32) { tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await; } + +/// 获取当前时间戳(毫秒)。 +/// +/// WASM 端使用 `js_sys::Date::now()`,服务端回退到 `chrono::Utc`。 +pub fn now_millis() -> i64 { + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() as i64 + } + #[cfg(not(target_arch = "wasm32"))] + { + chrono::Utc::now().timestamp_millis() + } +} + +/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 YYYY-MM-DD)。 +/// +/// 分档规则与服务端 `format_relative_time` 完全一致,前端在展示待审核评论时复用, +/// 保证两类评论的时间展示口径统一。返回绝对日期用于 `title` 悬浮提示。 +/// +/// - `delta_millis`:目标时间与"现在"的差值(毫秒)。正值表示过去,负值表示未来(兜底按刚刚处理)。 +/// - `created_iso`:评论的 RFC3339 创建时间,用于兜底生成绝对日期。 +pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (String, String) { + let seconds = delta_millis / 1000; + + let label = if seconds < 60 { + "刚刚".to_string() + } else { + let minutes = seconds / 60; + if minutes < 60 { + format!("{} 分钟前", minutes) + } else { + let hours = minutes / 60; + if hours < 24 { + format!("{} 小时前", hours) + } else { + let days = hours / 24; + if days < 30 { + format!("{} 天前", days) + } else { + // 超过 30 天直接显示日期,下方 absolute 复用 + String::new() + } + } + } + }; + + // 绝对日期:优先解析 ISO;解析失败时退化为空串,避免组件报错。 + let absolute = DateTime::parse_from_rfc3339(created_iso) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + + let label = if label.is_empty() { + absolute.clone() + } else { + label + }; + (label, absolute) +} + +/// 前端友好的相对时间格式化:返回相对文本,用于展示待审核评论的创建时间。 +/// +/// 这是 `relative_label_from_millis` 的薄封装,仅返回相对文本。 +pub fn format_relative_time_iso(created_iso: &str) -> String { + // 解析失败时退化为 "刚刚",避免组件崩溃。 + let Ok(dt) = DateTime::parse_from_rfc3339(created_iso) else { + return "刚刚".to_string(); + }; + let delta_millis = now_millis() - dt.timestamp_millis(); + relative_label_from_millis(delta_millis, created_iso).0 +} + +#[cfg(test)] +mod tests { + use super::*; + + const ISO: &str = "2026-06-22T05:43:57.565+00:00"; + + #[test] + fn relative_label_just_now_under_60s() { + let (label, _) = relative_label_from_millis(0, ISO); + assert_eq!(label, "刚刚"); + let (label, _) = relative_label_from_millis(59_999, ISO); + assert_eq!(label, "刚刚"); + } + + #[test] + fn relative_label_minutes() { + let (label, _) = relative_label_from_millis(60_000, ISO); + assert_eq!(label, "1 分钟前"); + let (label, _) = relative_label_from_millis(5 * 60_000, ISO); + assert_eq!(label, "5 分钟前"); + let (label, _) = relative_label_from_millis(59 * 60_000, ISO); + assert_eq!(label, "59 分钟前"); + } + + #[test] + fn relative_label_hours() { + let (label, _) = relative_label_from_millis(60 * 60_000, ISO); + assert_eq!(label, "1 小时前"); + let (label, _) = relative_label_from_millis(3 * 3_600_000, ISO); + assert_eq!(label, "3 小时前"); + let (label, _) = relative_label_from_millis(23 * 3_600_000, ISO); + assert_eq!(label, "23 小时前"); + } + + #[test] + fn relative_label_days() { + let (label, _) = relative_label_from_millis(24 * 3_600_000, ISO); + assert_eq!(label, "1 天前"); + let (label, _) = relative_label_from_millis(7 * 24 * 3_600_000, ISO); + assert_eq!(label, "7 天前"); + let (label, _) = relative_label_from_millis(29 * 24 * 3_600_000, ISO); + assert_eq!(label, "29 天前"); + } + + #[test] + fn relative_label_falls_back_to_date_over_30_days() { + let (label, absolute) = relative_label_from_millis(60 * 24 * 3_600_000, ISO); + assert_eq!(label, "2026-06-22"); + assert_eq!(absolute, "2026-06-22"); + } + + #[test] + fn relative_label_future_falls_back_to_just_now() { + // 未来时间差为负,秒数 < 60,归为"刚刚"。 + let (label, _) = relative_label_from_millis(-5_000, ISO); + assert_eq!(label, "刚刚"); + } + + #[test] + fn relative_label_invalid_iso_still_returns_absolute_empty() { + // 无法解析时 absolute 为空,但分档逻辑仍按 delta 决定。 + let (label, absolute) = relative_label_from_millis(0, "not-a-date"); + assert_eq!(label, "刚刚"); + assert_eq!(absolute, ""); + } + + #[test] + fn format_relative_time_iso_invalid_iso_falls_back() { + // 解析失败退化为"刚刚",不 panic。 + assert_eq!(format_relative_time_iso("not-a-date"), "刚刚"); + } +}