diff --git a/src/context.rs b/src/context.rs index 4f33505..bf49bd9 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,10 +1,17 @@ +//! 前端全局上下文定义。 +//! +//! 当前保存当前登录用户的信息,供 Dioxus 组件在客户端与服务端渲染期间共享访问。 + use dioxus::prelude::*; use std::sync::Arc; use crate::models::user::PublicUser; +/// 用户上下文,用于在组件树中传递登录状态。 #[derive(Clone, Copy)] pub struct UserContext { + /// 当前登录用户,未登录时为 `None`。 pub user: Signal>>, + /// 是否已完成会话校验,避免重复触发验证请求。 pub checked: Signal, } diff --git a/src/highlight.rs b/src/highlight.rs index e83fcc2..872bbc7 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1,3 +1,8 @@ +//! 语法高亮模块。 +//! +//! 仅在 `server` feature 启用时可用,使用 `syntect` 将代码块转换为带 CSS class 的 HTML, +//! 配合 `public/highlight.css` 中生成的主题规则实现亮/暗主题高亮。 + #[cfg(feature = "server")] pub mod server { use std::sync::LazyLock; @@ -6,6 +11,7 @@ pub mod server { use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; + /// 全局语法集合,懒加载时合并内置语法与 `syntaxes/` 目录下的自定义语法。 static SYNTAX_SET: LazyLock = LazyLock::new(|| { let mut builder = SyntaxSet::load_defaults_newlines().into_builder(); if let Err(e) = builder.add_from_folder("syntaxes/", true) { @@ -14,16 +20,23 @@ pub mod server { builder.build() }); + /// 根据语言标识查找对应的语法定义。 + /// + /// 依次尝试:扩展名、语法名称、小写扩展名/名称、常用别名映射。 + /// 如果全部失败,则回退到纯文本语法。 fn find_syntax(lang: Option<&str>) -> &'static syntect::parsing::SyntaxReference { let ss = &*SYNTAX_SET; if let Some(lang) = lang { if !lang.is_empty() { + // 尝试按扩展名匹配 if let Some(s) = ss.find_syntax_by_extension(lang) { return s; } + // 尝试按语法名称匹配 if let Some(s) = ss.find_syntax_by_name(lang) { return s; } + // 小写后再匹配一次 let lower = lang.to_lowercase(); if lower != lang { if let Some(s) = ss.find_syntax_by_extension(&lower) { @@ -33,6 +46,7 @@ pub mod server { return s; } } + // 常用语言别名映射表 let aliases: &[(&str, &str)] = &[ ("rust", "rs"), ("js", "js"), @@ -68,6 +82,9 @@ pub mod server { .expect("no plain text syntax") } + /// 对给定代码字符串按指定语言进行高亮,返回 HTML 字符串。 + /// + /// 输出使用 spaced CSS class 风格,便于与 `highlight.css` 中的选择器匹配。 pub fn highlight_code(code: &str, lang: Option<&str>) -> String { let trimmed = code.trim(); let syntax = find_syntax(lang); @@ -75,6 +92,7 @@ pub mod server { let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, ss, ClassStyle::Spaced); + // 逐行解析,出错时记录警告并继续 for line in LinesWithEndings::from(trimmed) { if let Err(e) = generator.parse_html_for_line_which_includes_newline(line) { tracing::warn!("syntect parse error: {:?}", e); diff --git a/src/tasks/ip_purge.rs b/src/tasks/ip_purge.rs index 74c90d4..4537f96 100644 --- a/src/tasks/ip_purge.rs +++ b/src/tasks/ip_purge.rs @@ -1,15 +1,22 @@ +//! IP 与用户代理信息定期清理后台任务。 +//! +//! 仅在 `server` feature 启用时编译,每天运行一次。 + use std::time::Duration; use tokio::time::interval; use crate::db::pool::get_conn; +/// 启动 IP 信息清理循环,每天将 90 天前的评论的 `ip_address` 与 `user_agent` 置空。 pub async fn run_purge() { + // 每天触发一次 let mut ticker = interval(Duration::from_secs(86400)); loop { ticker.tick().await; match get_conn().await { Ok(client) => { + // 仅清理 90 天前且仍保留 IP 的评论 if let Err(e) = client .execute("UPDATE comments SET ip_address = NULL, user_agent = NULL WHERE created_at < NOW() - INTERVAL '90 days' AND ip_address IS NOT NULL", &[]) .await diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index 789dfa5..63c4af2 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -1,4 +1,10 @@ +//! 后台任务调度入口。 +//! +//! 所有任务仅在 `server` feature 启用时编译,运行在服务端独立的 tokio 任务中。 + +/// 定时清理评论过期的 IP 与用户代理信息,满足隐私保护要求。 #[cfg(feature = "server")] pub mod ip_purge; +/// 定时删除已过期会话,避免 `sessions` 表无限增长。 #[cfg(feature = "server")] pub mod session_cleanup; diff --git a/src/tasks/session_cleanup.rs b/src/tasks/session_cleanup.rs index df32c5c..7eac39b 100644 --- a/src/tasks/session_cleanup.rs +++ b/src/tasks/session_cleanup.rs @@ -1,15 +1,22 @@ +//! 会话过期清理后台任务。 +//! +//! 仅在 `server` feature 启用时编译,每小时运行一次。 + use std::time::Duration; use tokio::time::interval; use crate::db::pool::get_conn; +/// 启动会话清理循环,每小时删除 `expires_at < NOW()` 的过期会话。 pub async fn run_cleanup() { + // 每小时触发一次 let mut ticker = interval(Duration::from_secs(3600)); loop { ticker.tick().await; match get_conn().await { Ok(client) => { + // 删除已过期会话 if let Err(e) = client .execute("DELETE FROM sessions WHERE expires_at < NOW()", &[]) .await diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d89e655..90cd467 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,9 @@ +//! 通用工具函数子模块。 +//! +//! `text` 模块仅在 `server` feature 启用时编译;`time` 模块同时提供 WASM 与原生异步版本。 + +/// Markdown / 纯文本处理工具。 #[cfg(feature = "server")] pub mod text; +/// 跨平台的异步睡眠等时间工具。 pub mod time; diff --git a/src/utils/text.rs b/src/utils/text.rs index 21f4278..c2f8c06 100644 --- a/src/utils/text.rs +++ b/src/utils/text.rs @@ -1,26 +1,39 @@ +//! Markdown 与文本处理工具。 +//! +//! 提供移除 Markdown 标记、字数统计、自动生成摘要等功能。 + use std::sync::LazyLock; +/// 匹配 fenced code block(```...```)的正则。 static CODE_BLOCK_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"```[\s\S]*?```").unwrap()); +/// 匹配行内代码(`...`)的正则。 static INLINE_CODE_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"`[^`]*`").unwrap()); +/// 匹配 Markdown 链接 `[text](url)` 的正则。 static LINK_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap()); +/// 匹配 Markdown 标题(# 到 ######)的正则。 static HEADING_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"^#{1,6}\s*").unwrap()); +/// 匹配 Markdown 图片 `![alt](url)` 的正则。 static IMAGE_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap()); +/// 匹配任意空白字符的正则,用于把多个空白合并为单个空格。 static WHITESPACE_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"\s+").unwrap()); +/// 去除 Markdown 标记,返回近似纯文本。 +/// +/// 处理顺序:代码块 → 行内代码 → 图片 → 链接(保留文字)→ 标题 → 加粗/斜体 → 合并空白。 pub fn strip_markdown(md: &str) -> String { let mut plain = CODE_BLOCK_RE.replace_all(md, "").to_string(); plain = INLINE_CODE_RE.replace_all(&plain, "").to_string(); - // Must strip images BEFORE links, otherwise `![](url)` becomes `!` + // 必须先移除图片再处理链接,否则 `![](url)` 会残留 `!` plain = IMAGE_RE.replace_all(&plain, "").to_string(); plain = LINK_RE.replace_all(&plain, "$1").to_string(); plain = HEADING_RE.replace_all(&plain, "").to_string(); @@ -33,12 +46,17 @@ pub fn strip_markdown(md: &str) -> String { plain.trim().to_string() } +/// 统计 Markdown 文本的有效字数。 +/// +/// 中文字符每个计 1;英文字母按连续字母串计 1 个词。 +/// 空文本返回 1,避免摘要或列表中出现 0 字的显示问题。 pub fn count_words(md: &str) -> u32 { let plain = strip_markdown(md); let mut count = 0u32; let mut in_word = false; for c in plain.chars() { + // CJK 统一表意文字范围(基本区) if c as u32 >= 0x4E00 && c as u32 <= 0x9FFF { count += 1; in_word = false; @@ -54,6 +72,7 @@ pub fn count_words(md: &str) -> u32 { count.max(1) } +/// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。 pub fn auto_summary(md: &str) -> String { let plain = strip_markdown(md); plain.chars().take(200).collect() diff --git a/src/utils/time.rs b/src/utils/time.rs index e84cef9..2de5acb 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -1,3 +1,10 @@ +//! 跨平台时间/睡眠工具。 +//! +//! 根据目标架构分别实现: +//! - `wasm32`:通过 `js_sys` 调用 JavaScript 的 `setTimeout`。 +//! - 其他平台:使用 `tokio::time::sleep`。 + +/// 异步睡眠指定毫秒数。 #[cfg(target_arch = "wasm32")] pub async fn sleep_ms(ms: u32) { use wasm_bindgen::JsCast; @@ -9,6 +16,7 @@ pub async fn sleep_ms(ms: u32) { } } +/// 异步睡眠指定毫秒数(原生 tokio 版本)。 #[cfg(not(target_arch = "wasm32"))] pub async fn sleep_ms(ms: u32) { tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await; diff --git a/src/webp.rs b/src/webp.rs index b16de05..1056587 100644 --- a/src/webp.rs +++ b/src/webp.rs @@ -1,10 +1,27 @@ +//! WebP 编解码模块。 +//! +//! 本模块仅在 `server` feature 启用时编译。 +//! +//! ## `zenwebp` 与 `image` crate 的分工 +//! +//! - `image` crate:负责通用图像格式(JPEG、PNG、GIF 等)的解码、缩放、旋转以及 +//! 像素格式转换(`DynamicImage`、`RgbaImage`、`RgbImage`)。本项目特意禁用了 `image` +//! 的 `webp` feature,因为它不支持 WebP 编码,且解码能力有限。 +//! - `zenwebp`:专门负责 WebP 格式的有损编码与解码。所有需要输出 WebP 或读取 WebP +//! 字节流的场景都通过 `zenwebp` 完成。 +//! +//! 简言之:`image` 处理“除 WebP 外的图像操作”,`zenwebp` 处理“WebP 专有编解码”。 + #[cfg(feature = "server")] use std::sync::LazyLock; +/// WebP 编解码过程中可能产生的错误。 #[cfg(feature = "server")] #[derive(Debug)] pub enum WebpError { + /// 编码失败。 Encode(String), + /// 解码失败。 Decode(String), } @@ -21,13 +38,20 @@ impl std::fmt::Display for WebpError { #[cfg(feature = "server")] impl std::error::Error for WebpError {} +/// WebP 有损编码配置。 #[cfg(feature = "server")] #[derive(Debug, Clone)] pub struct WebpConfig { + /// 质量系数,范围 0.0–100.0。 pub quality: f32, + /// 编码方法,范围 0–6,数值越大压缩率越高但越慢。 pub method: u8, } +/// 从环境变量读取的 WebP 全局配置,未设置时使用默认值。 +/// +/// - `WEBP_QUALITY`:默认 85.0,越界时 clamp 到 0.0–100.0。 +/// - `WEBP_METHOD`:默认 2,越界时 clamp 到 0–6。 #[cfg(feature = "server")] pub static WEBP_CONFIG: LazyLock = LazyLock::new(|| { let (quality, quality_clamped) = std::env::var("WEBP_QUALITY") @@ -68,6 +92,9 @@ pub static WEBP_CONFIG: LazyLock = LazyLock::new(|| { WebpConfig { quality, method } }); +/// 将 `image::DynamicImage` 编码为 WebP 字节流。 +/// +/// 直接处理 `Rgba8` 与 `Rgb8` 两种像素布局,其他格式先转换为 `Rgba8` 再编码。 #[cfg(feature = "server")] pub fn encode(img: &image::DynamicImage, quality: f32, method: u8) -> Result, WebpError> { use zenwebp::{EncodeRequest, LossyConfig, PixelLayout}; @@ -95,13 +122,17 @@ pub fn encode(img: &image::DynamicImage, quality: f32, method: u8) -> Result { - // Convert other formats to RGBA8 + // 其他像素格式统一转换为 RGBA8 后再交给 zenwebp 编码 let rgba = img.to_rgba8(); do_encode(&config, rgba.as_raw(), PixelLayout::Rgba8, width, height) } } } +/// 将 WebP 字节流解码为 `image::DynamicImage`。 +/// +/// 根据 alpha 通道信息决定返回 `ImageRgba8` 还是 `ImageRgb8`。 +/// 解码前会校验像素总数,防止超大图片导致内存问题。 #[cfg(feature = "server")] pub fn decode(data: &[u8]) -> Result { use zenwebp::WebPDecoder; @@ -116,6 +147,7 @@ pub fn decode(data: &[u8]) -> Result { let pixel_count = (width as u64) * (height as u64); + // 超过最大允许像素数时提前拒绝 if pixel_count > crate::api::image::MAX_IMAGE_PIXELS as u64 { return Err(WebpError::Decode(format!( "Image dimensions {}x{} exceed maximum allowed pixels", @@ -137,7 +169,7 @@ pub fn decode(data: &[u8]) -> Result { .map(image::DynamicImage::ImageRgba8) .ok_or_else(|| WebpError::Decode("Invalid RGBA dimensions".to_string())) } else { - // For RGB output, the buffer is width * height * 3 + // 无 alpha 时,zenwebp 输出的是 width * height * 3 的 RGB 数据 image::RgbImage::from_raw(width, height, output) .map(image::DynamicImage::ImageRgb8) .ok_or_else(|| WebpError::Decode("Invalid RGB dimensions".to_string()))