From 26b012c40c0fa86755fa61a5243157837da78a70 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 12 Jun 2026 18:27:24 +0800 Subject: [PATCH] =?UTF-8?q?docs(api,=20auth):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/error.rs | 17 +++++++++++++++++ src/api/image.rs | 22 ++++++++++++++++++++++ src/api/markdown.rs | 25 ++++++++++++++++++++++--- src/api/mod.rs | 16 ++++++++++++++++ src/api/rate_limit.rs | 17 +++++++++++++++++ src/api/sanitizer.rs | 11 +++++++++++ src/api/slug.rs | 18 ++++++++++++++++++ src/api/upload.rs | 15 +++++++++++++++ src/auth/mod.rs | 7 +++++++ src/auth/password.rs | 7 +++++++ src/auth/session.rs | 13 +++++++++++++ 12 files changed, 207 insertions(+), 3 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index a663bf3..cbc1172 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,3 +1,10 @@ +//! 认证相关的 Dioxus server function 与辅助函数。 +//! +//! 提供注册、登录、登出、获取当前用户等接口, +//! 通过 HttpOnly Cookie 维护会话,首个注册用户自动成为 admin。 +//! 所有 server function 均在 `#[server(Name, "/api")]` 下注册,供客户端与服务端调用。 +//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与 Cookie 写入。 + #![allow(clippy::unused_unit, deprecated)] use dioxus::prelude::*; @@ -41,18 +48,28 @@ fn validate_password(password: &str) -> Result<(), String> { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// 认证接口统一响应结构。 pub struct AuthResponse { + /// 操作是否成功。 pub success: bool, + /// 提示信息。 pub message: String, + /// 登录成功后的会话 token(已废弃,实际通过 Cookie 传递)。 pub token: Option, } +/// 用户注册。 +/// +/// 校验用户名、邮箱、密码,首个注册用户自动设为 admin; +/// 已有 admin 时返回 "Registration is closed"。 +/// Dioxus server function,注册在 `/api` 路径下。 #[server(Register, "/api")] pub async fn register( username: String, email: String, password: String, ) -> Result { + // 服务端构建时先进行严格限流检查。 #[cfg(feature = "server")] { if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { @@ -92,6 +109,7 @@ pub async fn register( let client = get_conn().await.map_err(AppError::db_conn)?; + // 仅允许第一个注册用户注册为 admin,其余拒绝。 let admin_count: i64 = client .query_one("SELECT COUNT(*) FROM users WHERE role = 'admin'", &[]) .await @@ -137,8 +155,14 @@ pub async fn register( } } +/// 用户登录。 +/// +/// 验证用户名/邮箱与密码,生成会话并写入 HttpOnly Cookie; +/// 同一用户活跃会话数超过 `MAX_SESSIONS_PER_USER` 时删除最早会话。 +/// Dioxus server function,注册在 `/api` 路径下。 #[server(Login, "/api")] pub async fn login(username: String, password: String) -> Result { + // 服务端构建时先进行严格限流检查。 #[cfg(feature = "server")] { if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { @@ -199,6 +223,7 @@ pub async fn login(username: String, password: String) -> Result NOW()", @@ -232,6 +257,7 @@ pub async fn login(username: String, password: String) -> Result Result Result { let token = get_session_from_ctx(); let client = get_conn().await.map_err(AppError::db_conn)?; + // 设置过期时间为 0 的 Cookie,通知浏览器清除会话。 let cookie = session::session_cookie("", 0, session::cookie_secure()); if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { if let Ok(value) = HeaderValue::try_from(cookie.as_str()) { @@ -274,11 +305,16 @@ pub async fn logout() -> Result { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// 当前用户查询响应。 pub struct CurrentUserResponse { + /// 当前已登录用户的公开信息;未登录时为 `None`。 pub user: Option, } #[cfg(feature = "server")] +/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。 +/// +/// 仅服务端内部使用,不会暴露给前端。 pub async fn get_user_by_token(token: &str) -> Result, ServerFnError> { let client = get_conn().await.map_err(AppError::db_conn)?; @@ -313,6 +349,9 @@ pub async fn get_user_by_token(token: &str) -> Result, ServerFnErro Ok(user) } +/// 获取当前登录用户的公开信息。 +/// +/// Dioxus server function,注册在 `/api` 路径下。 #[server(GetCurrentUser, "/api")] pub async fn get_current_user() -> Result { let token = match get_session_from_ctx() { @@ -326,6 +365,9 @@ pub async fn get_current_user() -> Result { } #[cfg(feature = "server")] +/// 获取当前登录用户并要求其为 admin,否则返回 401/403。 +/// +/// 供其它服务端接口内部调用。 pub async fn get_current_admin_user() -> Result { let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; diff --git a/src/api/error.rs b/src/api/error.rs index db3c32d..e69e1cc 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -1,34 +1,51 @@ +//! 应用错误类型与 `ServerFnError` 转换。 +//! +//! `AppError` 封装认证、权限、数据库、内部错误等场景, +//! 并转换为对外友好的 `ServerFnError` 消息,避免泄露 SQL 细节。 + use dioxus::prelude::ServerFnError; +/// 应用层统一错误类型。 #[derive(Debug)] pub enum AppError { + /// 未认证(401)。 Unauthorized(&'static str), + /// 无权限(403)。 Forbidden(&'static str), + /// 资源不存在(404)。 NotFound(&'static str), + /// 数据库连接失败。 DbConn(String), + /// SQL 查询执行失败。 Query(String), + /// 数据库事务失败。 Transaction(String), + /// 内部通用错误。 Internal(&'static str), } #[cfg(feature = "server")] impl AppError { + /// 记录并包装数据库连接错误。 pub fn db_conn(e: impl std::fmt::Debug) -> Self { tracing::error!("DB connection failed: {e:?}"); AppError::DbConn(format!("{e:?}")) } + /// 记录并包装 SQL 查询错误。 pub fn query(e: impl std::fmt::Debug) -> Self { tracing::error!("Query failed: {e:?}"); AppError::Query(format!("{e:?}")) } + /// 记录并包装数据库事务错误。 pub fn tx(e: impl std::fmt::Debug) -> Self { tracing::error!("Transaction failed: {e:?}"); AppError::Transaction(format!("{e:?}")) } } +/// 转换为 `ServerFnError`,对数据库类错误返回通用中文提示。 impl From for ServerFnError { fn from(err: AppError) -> ServerFnError { let msg = match &err { diff --git a/src/api/image.rs b/src/api/image.rs index 9c63938..f40d201 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -1,3 +1,10 @@ +//! 图片服务的 Axum 处理器与处理流水线。 +//! +//! 支持按宽度/高度、缩略图、旋转角度、输出格式/质量动态处理图片, +//! 使用内存(moka)+ 磁盘两级缓存加速响应。 +//! WebP 编解码走 `zenwebp`(`image` crate 未启用 WebP feature)。 +//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。 + #[cfg(feature = "server")] use axum::{ extract::{Path, Query}, @@ -16,10 +23,12 @@ const MAX_IMAGE_DIMENSION: u32 = 4096; #[cfg(feature = "server")] const DEFAULT_JPEG_QUALITY: u8 = 85; #[cfg(feature = "server")] +/// 允许处理的最大图片像素数(约 10k x 10k)。 pub const MAX_IMAGE_PIXELS: u32 = 100_000_000; // ~10k x 10k #[cfg(feature = "server")] #[derive(Debug, Clone)] +/// 缓存条目,保存处理后的图片字节与 Content-Type。 struct CachedImage { data: Vec, content_type: HeaderValue, @@ -35,12 +44,19 @@ static IMAGE_CACHE: LazyLock> = LazyLock::new(|| { #[cfg(feature = "server")] #[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)] +/// 图片处理查询参数。 pub struct ImageParams { + /// 限制最大宽度。 pub w: Option, + /// 限制最大高度。 pub h: Option, + /// 缩略图尺寸,格式 `WxH`。 pub thumb: Option, + /// 旋转角度,仅允许 0/90/180/270。 pub rotate: Option, + /// 输出格式:`jpeg`/`jpg`、`png`、`webp`。 pub format: Option, + /// 输出质量,范围 1-100。 pub quality: Option, } @@ -78,6 +94,7 @@ impl ImageParams { parts.join("|") } + /// 校验参数合法性,返回 HTTP 400 状态码表示非法。 fn validate(&self) -> Result<(), StatusCode> { if let Some(dim) = self.w { if dim == 0 || dim > MAX_IMAGE_DIMENSION { @@ -279,6 +296,10 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) { } #[cfg(feature = "server")] +/// 图片访问与动态处理的 Axum handler。 +/// +/// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 → +/// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。 pub async fn serve_image( Path(path): Path, Query(params): Query, @@ -344,6 +365,7 @@ pub async fn serve_image( Err(_) => return StatusCode::NOT_FOUND.into_response(), }; + // WebP 解码使用 zenwebp,其它格式使用 image crate。 let original_format = detect_format(&path); let img = if original_format == image::ImageFormat::WebP { match crate::webp::decode(&data) { diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 411928d..e55f89a 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -1,24 +1,36 @@ +//! Markdown 渲染与目录生成。 +//! +//! 使用 pulldown-cmark 解析 Markdown,为标题生成锚点与目录(TOC), +//! 代码块调用 `highlight` 模块进行语法高亮,最终通过 sanitizer 清理 HTML。 +//! 仅在 `feature = "server"` 时执行实际渲染。 + #![allow(clippy::unused_unit, deprecated)] #[cfg(feature = "server")] +/// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。 pub fn clean_html(input: &str) -> String { crate::api::sanitizer::clean_html(input) } #[derive(Debug, Clone)] #[cfg(feature = "server")] +/// Markdown 渲染结果。 pub struct RenderedContent { + /// 清理后的正文 HTML。 pub html: String, + /// 目录 HTML(无标题时为空字符串)。 pub toc_html: String, } #[cfg(feature = "server")] +/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。 pub fn render_markdown_enhanced(md: &str) -> RenderedContent { use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd}; // 1. Parse markdown and collect headings for TOC let parser = pulldown_cmark::Parser::new_ext(md, Options::all()); - let mut headings: Vec<(u8, String, String)> = Vec::new(); // (level, text, id) + // (level, text, id) + let mut headings: Vec<(u8, String, String)> = Vec::new(); let mut current_heading: Option<(u8, String)> = None; for event in parser { @@ -70,6 +82,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { + // 先把累积的普通事件刷入 HTML,再开始新标题。 if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); non_heading_events = Vec::new(); @@ -108,6 +121,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { in_heading = false; } Event::Start(Tag::CodeBlock(kind)) => { + // 代码块开始前同样先刷入未处理的普通事件。 if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); non_heading_events = Vec::new(); @@ -129,6 +143,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { code_buffer.push_str(&text); } Event::End(TagEnd::CodeBlock) => { + // 使用 syntect 对代码块进行服务端语法高亮。 let highlighted = crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); html.push_str("
 RenderedContent {
             }
             _ => {
                 if in_heading {
+                    // 标题内部只保留文本与行内代码,避免嵌套块级元素。
                     match event {
                         Event::Text(text) => html.push_str(&clean_html(&text)),
                         Event::Code(code) => {
@@ -170,6 +186,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
 }
 
 #[cfg(feature = "server")]
+/// 根据标题层级生成嵌套目录 HTML。
 fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
     if headings.is_empty() {
         return String::new();
@@ -184,13 +201,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
         if i > 0 {
             let prev_level = headings[i - 1].0;
             if level > prev_level {
-                // Open new nested lists
+                // 标题层级升高:打开新的嵌套列表。
                 for _ in prev_level..level {
                     html.push_str("
    "); stack.push(level); } } else if level < prev_level { - // Close nested lists + // 标题层级降低:关闭多余的嵌套列表。 while let Some(top) = stack.last() { if *top > level { html.push_str("
"); @@ -213,6 +230,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String { } // Close remaining lists + // 闭合所有残留的嵌套列表。 while stack.len() > 1 { html.push_str(""); stack.pop(); @@ -223,6 +241,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String { } #[cfg(feature = "server")] +/// 将标题文本转换为可用于锚点的 slug。 fn slugify_heading(text: &str) -> String { let mut slug = String::new(); let mut prev_dash = true; diff --git a/src/api/mod.rs b/src/api/mod.rs index 4d90924..719008c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,10 +1,26 @@ +//! API 层根模块。 +//! +//! 按职责划分子模块,包含两类接口: +//! - Dioxus server function(`#[server(Name, "/api")]`),如 `auth`、`posts`; +//! - Axum 手动路由处理器,如 `upload`、`image`。 + +/// 认证相关的 Dioxus server function。 pub mod auth; +/// 评论相关接口。 pub mod comments; +/// 应用错误类型与转换。 pub mod error; +/// 图片服务的 Axum 处理器。 pub mod image; +/// Markdown 渲染与 HTML 清理。 pub mod markdown; +/// 文章 CRUD 相关接口。 pub mod posts; +/// 限流工具。 pub mod rate_limit; +/// HTML 消毒器。 pub mod sanitizer; +/// URL slug 生成与校验。 pub mod slug; +/// 图片上传的 Axum 处理器。 pub mod upload; diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index 50d3612..d636602 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -1,3 +1,10 @@ +//! 基于 governor 的多级限流。 +//! +//! 提供 strict、upload、image、comment 四个限流器, +//! 支持从 `X-Forwarded-For` / `X-Real-IP` 中提取客户端 IP, +//! 并可通过 `TRUSTED_PROXY_COUNT` 配置信任代理层数。 +//! 仅在 `feature = "server"` 时生效。 + #[cfg(feature = "server")] use axum::http::StatusCode; #[cfg(feature = "server")] @@ -49,6 +56,7 @@ static COMMENT_LIMITER: LazyLock> = LazyLock::ne }); #[cfg(feature = "server")] +/// 检查评论请求是否超出限流阈值。 pub fn check_comment_limit(ip: &str) -> Result<(), String> { COMMENT_LIMITER .check_key(&ip.to_string()) @@ -57,6 +65,7 @@ pub fn check_comment_limit(ip: &str) -> Result<(), String> { } #[cfg(feature = "server")] +/// 检查图片访问请求是否超出限流阈值,返回 HTTP 状态码。 pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> { IMAGE_LIMITER .check_key(&ip.to_string()) @@ -74,6 +83,7 @@ fn trusted_proxy_count() -> usize { #[cfg(feature = "server")] fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option { + // 按逗号拆分并过滤空项,列表末尾是离服务端最近的代理。 let parts: Vec<&str> = value .split(',') .map(str::trim) @@ -82,14 +92,17 @@ fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option String { if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) { @@ -97,6 +110,7 @@ pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count } } + // 配置了信任代理时,回退到 X-Real-IP。 if trusted_proxy_count > 0 { if let Some(ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { return ip.trim().to_string(); @@ -107,11 +121,13 @@ pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count } #[cfg(feature = "server")] +/// 使用环境变量配置的代理层数提取客户端 IP。 pub fn get_client_ip(headers: &http::HeaderMap) -> String { get_client_ip_with_trusted(headers, trusted_proxy_count()) } #[cfg(feature = "server")] +/// 检查严格限流(注册、登录等敏感接口)。 pub fn check_strict_limit(ip: &str) -> Result<(), String> { STRICT_LIMITER .check_key(&ip.to_string()) @@ -120,6 +136,7 @@ pub fn check_strict_limit(ip: &str) -> Result<(), String> { } #[cfg(feature = "server")] +/// 检查上传请求是否超出限流阈值。 pub fn check_upload_limit(ip: &str) -> Result<(), String> { UPLOAD_LIMITER .check_key(&ip.to_string()) diff --git a/src/api/sanitizer.rs b/src/api/sanitizer.rs index b97afc1..9a5b30d 100644 --- a/src/api/sanitizer.rs +++ b/src/api/sanitizer.rs @@ -1,3 +1,9 @@ +//! HTML 消毒器。 +//! +//! 基于 lol_html 清理不受信任的 HTML,限制允许的 tag/attribute/URL scheme, +//! 分别提供文章正文(`clean_html`)与评论(`clean_comment_html`)两套白名单策略。 +//! 仅在 `feature = "server"` 时执行。 + #![allow(clippy::unused_unit, deprecated)] #[cfg(feature = "server")] @@ -137,6 +143,7 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) if trimmed.is_empty() { return true; } + // 解析 scheme 并与白名单对比;data URI 与 javascript/vbscript 单独处理。 if let Some(colon_pos) = trimmed.find(':') { let scheme = &trimmed[..colon_pos]; let scheme_lower = scheme.to_lowercase(); @@ -160,6 +167,7 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) } #[cfg(feature = "server")] +/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。 struct SanitizerConfig { allowed_tags: HashSet<&'static str>, extra_generic_attrs: Vec<&'static str>, @@ -251,6 +259,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String { .filter_map(|attr| { let name = attr.name(); let name_lower = name.to_lowercase(); + // 仅保留白名单属性;对 href/src/cite 额外校验 URL 安全性。 if allowed_for_tag.contains(name_lower.as_str()) { if name_lower == "href" || name_lower == "src" || name_lower == "cite" { let val = attr.value(); @@ -296,6 +305,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String { } #[cfg(feature = "server")] +/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。 pub fn clean_html(input: &str) -> String { let config = SanitizerConfig { allowed_tags: default_allowed_tags(), @@ -327,6 +337,7 @@ pub fn clean_html(input: &str) -> String { } #[cfg(feature = "server")] +/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。 pub fn clean_comment_html(input: &str) -> String { let mut tags = default_allowed_tags(); tags.remove("img"); diff --git a/src/api/slug.rs b/src/api/slug.rs index 8f88808..4c0a3d0 100644 --- a/src/api/slug.rs +++ b/src/api/slug.rs @@ -1,8 +1,18 @@ +//! 文章 slug 生成与唯一性校验。 +//! +//! 将标题转换为小写、仅含字母数字与连字符/下划线的 URL 友好形式, +//! 并检测数据库中是否已存在,必要时追加数字后缀。 +//! 仅在 `feature = "server"` 时访问数据库。 + #![allow(clippy::unused_unit, deprecated)] use dioxus::prelude::*; #[cfg(feature = "server")] +/// 将标题转换为 URL 友好的 slug。 +/// +/// 非字母数字字符替换为 `-` 并合并连续 `-`,结果截断至 100 字符; +/// 若全部字符被过滤,则返回当前时间戳作为 slug。 pub fn slugify(title: &str) -> String { let slug: String = title .to_lowercase() @@ -16,6 +26,7 @@ pub fn slugify(title: &str) -> String { }) .collect(); + // 合并连续的连字符,并去除首尾空段。 let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect(); let slug = parts.join("-"); @@ -27,6 +38,7 @@ pub fn slugify(title: &str) -> String { } #[cfg(feature = "server")] +/// 校验 slug 是否为空且仅含合法字符、长度不超过 200。 pub fn is_valid_slug(slug: &str) -> bool { if slug.is_empty() || slug.len() > 200 { return false; @@ -36,6 +48,10 @@ pub fn is_valid_slug(slug: &str) -> bool { } #[cfg(feature = "server")] +/// 确保生成的 slug 在数据库中唯一。 +/// +/// 若 `exclude_id` 不为空,则排除该文章自身; +/// 当冲突时依次尝试 `base-2`、`base-3` …… 直到生成唯一值。 pub async fn ensure_unique_slug( client: &tokio_postgres::Client, base: &str, @@ -47,6 +63,7 @@ pub async fn ensure_unique_slug( let mut suffix = 2; loop { + // 查询当前候选 slug 是否已存在(排除指定文章 ID)。 let exists = if let Some(exclude) = exclude_id { client .query_opt( @@ -74,6 +91,7 @@ pub async fn ensure_unique_slug( candidate = format!("{}-{}", base, suffix); suffix += 1; + // 防止无限循环:slug 总长度超过 200 时直接报错。 if candidate.len() > 200 { return Err(AppError::Internal("无法生成唯一 slug").into()); } diff --git a/src/api/upload.rs b/src/api/upload.rs index e14476e..2df61e2 100644 --- a/src/api/upload.rs +++ b/src/api/upload.rs @@ -1,3 +1,10 @@ +//! 图片上传的 Axum 处理器。 +//! +//! 处理 multipart 上传,校验 MIME 类型、文件大小与 admin 权限, +//! JPEG/PNG 自动转 WebP(若体积更小则保留原格式),GIF/WebP 保持原样。 +//! 文件按日期分目录存放于 `uploads/`。 +//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。 + #[cfg(feature = "server")] use axum::{ extract::Multipart, @@ -27,6 +34,10 @@ fn mime_to_ext(mime: &str) -> &'static str { } #[cfg(feature = "server")] +/// 处理图片上传的 Axum handler。 +/// +/// 流程:限流 → 解析 session → 校验 admin → 读取 multipart → 校验类型/大小 → +/// 转码(如适用)→ 按日期落盘 → 返回相对 URL。 pub async fn upload_image( headers: HeaderMap, mut multipart: Multipart, @@ -150,6 +161,7 @@ pub async fn upload_image( let is_gif = mime_type.as_str() == "image/gif"; let is_webp = mime_type.as_str() == "image/webp"; + // GIF 与 WebP 保持原格式;其余格式尝试转 WebP。 let (final_data, final_ext) = if is_gif { (data.to_vec(), "gif".to_string()) } else if is_webp { @@ -158,6 +170,7 @@ pub async fn upload_image( let original_data = data.to_vec(); let mime = mime_type.clone(); let config = crate::webp::WEBP_CONFIG.clone(); + // 在阻塞线程中执行图片解码与 WebP 编码,避免阻塞异步运行时。 let result = tokio::task::spawn_blocking(move || -> (Vec, String, bool) { let total_start = std::time::Instant::now(); match image::load_from_memory(&original_data) { @@ -168,6 +181,7 @@ pub async fn upload_image( Ok(webp_data) => { let enc_time = enc_start.elapsed(); let total_time = total_start.elapsed(); + // WebP 更小才采用,否则回退原格式以节省带宽。 if webp_data.len() < original_data.len() { tracing::info!( "WebP conversion: decode={:?} encode={:?} total={:?} {}x{} {} bytes -> {} bytes", @@ -210,6 +224,7 @@ pub async fn upload_image( } }; + // 按上传时间组织目录:uploads/YYYY/MM/DD。 let now = chrono::Utc::now(); let year = now.format("%Y").to_string(); let month = now.format("%m").to_string(); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 5348a28..f029fb5 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,2 +1,9 @@ +//! 认证辅助模块。 +//! +//! 包含密码哈希(Argon2)与会话 token 管理两个子模块。 + +/// 密码哈希(Argon2)子模块。 pub mod password; + +/// 会话 token 管理子模块。 pub mod session; diff --git a/src/auth/password.rs b/src/auth/password.rs index d9e5195..c4348ff 100644 --- a/src/auth/password.rs +++ b/src/auth/password.rs @@ -1,3 +1,8 @@ +//! 密码哈希与校验(Argon2)。 +//! +//! 使用随机 salt 生成 PHC 字符串格式哈希,并通过 Argon2 验证。 +//! `#[allow(dead_code)]` 用于避免 WASM 构建中服务端函数体被剥离后的未使用警告。 + use argon2::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, @@ -5,6 +10,7 @@ use argon2::{ use rand::rngs::OsRng; #[allow(dead_code)] +/// 使用 Argon2 对明文密码进行哈希。 pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); @@ -13,6 +19,7 @@ pub fn hash_password(password: &str) -> Result Result { let parsed_hash = PasswordHash::new(hash)?; let argon2 = Argon2::default(); diff --git a/src/auth/session.rs b/src/auth/session.rs index 582bba1..575c4b3 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -1,13 +1,21 @@ +//! 会话 token 生成、哈希与 Cookie 处理。 +//! +//! token 使用 UUID,存储时使用 SHA-256 哈希, +//! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。 +//! 服务端上下文解析函数仅在 `feature = "server"` 时可用。 + use chrono::{DateTime, Duration, Utc}; use sha2::{Digest, Sha256}; use uuid::Uuid; #[allow(dead_code)] +/// 生成新的随机会话 token(UUID 格式)。 pub fn generate_token() -> String { Uuid::new_v4().to_string() } #[allow(dead_code)] +/// 使用 SHA-256 对 token 进行哈希,用于数据库存储。 pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); @@ -15,11 +23,13 @@ pub fn hash_token(token: &str) -> String { } #[allow(dead_code)] +/// 返回默认会话过期时间(当前时间 + 30 天)。 pub fn default_expiry() -> DateTime { Utc::now() + Duration::days(30) } #[cfg(feature = "server")] +/// 读取环境变量 `COOKIE_SECURE`,决定是否给 Cookie 添加 Secure 标志。 pub fn cookie_secure() -> bool { std::env::var("COOKIE_SECURE") .ok() @@ -28,6 +38,7 @@ pub fn cookie_secure() -> bool { } #[cfg(feature = "server")] +/// 构造会话 Cookie 字符串,包含 HttpOnly、Path、Max-Age 与 SameSite。 pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String { let secure_flag = if secure { "; Secure" } else { "" }; format!( @@ -36,6 +47,7 @@ pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String } #[cfg(feature = "server")] +/// 从 `Cookie` 头中解析名为 `session` 的 token 值。 pub fn parse_session_token(cookie_header: &str) -> Option<&str> { cookie_header.split(';').map(|s| s.trim()).find_map(|pair| { let mut parts = pair.splitn(2, '='); @@ -50,6 +62,7 @@ pub fn parse_session_token(cookie_header: &str) -> Option<&str> { } #[cfg(feature = "server")] +/// 从 Dioxus `FullstackContext` 中读取 Cookie 并返回会话 token。 pub fn get_session_from_ctx() -> Option { use dioxus::fullstack::FullstackContext;