docs(api, auth): 补充中文注释

This commit is contained in:
xfy 2026-06-12 18:27:24 +08:00
parent 4fe26f7eb3
commit 26b012c40c
12 changed files with 207 additions and 3 deletions

View File

@ -1,3 +1,10 @@
//! 认证相关的 Dioxus server function 与辅助函数。
//!
//! 提供注册、登录、登出、获取当前用户等接口,
//! 通过 HttpOnly Cookie 维护会话,首个注册用户自动成为 admin。
//! 所有 server function 均在 `#[server(Name, "/api")]` 下注册,供客户端与服务端调用。
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与 Cookie 写入。
#![allow(clippy::unused_unit, deprecated)] #![allow(clippy::unused_unit, deprecated)]
use dioxus::prelude::*; use dioxus::prelude::*;
@ -41,18 +48,28 @@ fn validate_password(password: &str) -> Result<(), String> {
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
/// 认证接口统一响应结构。
pub struct AuthResponse { pub struct AuthResponse {
/// 操作是否成功。
pub success: bool, pub success: bool,
/// 提示信息。
pub message: String, pub message: String,
/// 登录成功后的会话 token已废弃实际通过 Cookie 传递)。
pub token: Option<String>, pub token: Option<String>,
} }
/// 用户注册。
///
/// 校验用户名、邮箱、密码,首个注册用户自动设为 admin
/// 已有 admin 时返回 "Registration is closed"。
/// Dioxus server function注册在 `/api` 路径下。
#[server(Register, "/api")] #[server(Register, "/api")]
pub async fn register( pub async fn register(
username: String, username: String,
email: String, email: String,
password: String, password: String,
) -> Result<AuthResponse, ServerFnError> { ) -> Result<AuthResponse, ServerFnError> {
// 服务端构建时先进行严格限流检查。
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { 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)?; let client = get_conn().await.map_err(AppError::db_conn)?;
// 仅允许第一个注册用户注册为 admin其余拒绝。
let admin_count: i64 = client let admin_count: i64 = client
.query_one("SELECT COUNT(*) FROM users WHERE role = 'admin'", &[]) .query_one("SELECT COUNT(*) FROM users WHERE role = 'admin'", &[])
.await .await
@ -137,8 +155,14 @@ pub async fn register(
} }
} }
/// 用户登录。
///
/// 验证用户名/邮箱与密码,生成会话并写入 HttpOnly Cookie
/// 同一用户活跃会话数超过 `MAX_SESSIONS_PER_USER` 时删除最早会话。
/// Dioxus server function注册在 `/api` 路径下。
#[server(Login, "/api")] #[server(Login, "/api")]
pub async fn login(username: String, password: String) -> Result<AuthResponse, ServerFnError> { pub async fn login(username: String, password: String) -> Result<AuthResponse, ServerFnError> {
// 服务端构建时先进行严格限流检查。
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
@ -199,6 +223,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
.unwrap_or(5) .unwrap_or(5)
.max(1); .max(1);
// 查询当前活跃会话数,超出限制时删除最早的一条。
let session_count: i64 = client let session_count: i64 = client
.query_one( .query_one(
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()", "SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
@ -232,6 +257,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
.map_err(AppError::query)?; .map_err(AppError::query)?;
let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure()); let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure());
// 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) { if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
ctx.add_response_header(SET_COOKIE, value); ctx.add_response_header(SET_COOKIE, value);
@ -245,12 +271,17 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
}) })
} }
/// 用户登出。
///
/// 清空客户端 session Cookie并删除数据库中对应会话记录。
/// Dioxus server function注册在 `/api` 路径下。
#[server(Logout, "/api")] #[server(Logout, "/api")]
pub async fn logout() -> Result<AuthResponse, ServerFnError> { pub async fn logout() -> Result<AuthResponse, ServerFnError> {
let token = get_session_from_ctx(); let token = get_session_from_ctx();
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
// 设置过期时间为 0 的 Cookie通知浏览器清除会话。
let cookie = session::session_cookie("", 0, session::cookie_secure()); let cookie = session::session_cookie("", 0, session::cookie_secure());
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) { if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
@ -274,11 +305,16 @@ pub async fn logout() -> Result<AuthResponse, ServerFnError> {
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
/// 当前用户查询响应。
pub struct CurrentUserResponse { pub struct CurrentUserResponse {
/// 当前已登录用户的公开信息;未登录时为 `None`。
pub user: Option<PublicUser>, pub user: Option<PublicUser>,
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。
///
/// 仅服务端内部使用,不会暴露给前端。
pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> { pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> {
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
@ -313,6 +349,9 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
Ok(user) Ok(user)
} }
/// 获取当前登录用户的公开信息。
///
/// Dioxus server function注册在 `/api` 路径下。
#[server(GetCurrentUser, "/api")] #[server(GetCurrentUser, "/api")]
pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> { pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
let token = match get_session_from_ctx() { let token = match get_session_from_ctx() {
@ -326,6 +365,9 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 获取当前登录用户并要求其为 admin否则返回 401/403。
///
/// 供其它服务端接口内部调用。
pub async fn get_current_admin_user() -> Result<User, AppError> { pub async fn get_current_admin_user() -> Result<User, AppError> {
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;

View File

@ -1,34 +1,51 @@
//! 应用错误类型与 `ServerFnError` 转换。
//!
//! `AppError` 封装认证、权限、数据库、内部错误等场景,
//! 并转换为对外友好的 `ServerFnError` 消息,避免泄露 SQL 细节。
use dioxus::prelude::ServerFnError; use dioxus::prelude::ServerFnError;
/// 应用层统一错误类型。
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { pub enum AppError {
/// 未认证401
Unauthorized(&'static str), Unauthorized(&'static str),
/// 无权限403
Forbidden(&'static str), Forbidden(&'static str),
/// 资源不存在404
NotFound(&'static str), NotFound(&'static str),
/// 数据库连接失败。
DbConn(String), DbConn(String),
/// SQL 查询执行失败。
Query(String), Query(String),
/// 数据库事务失败。
Transaction(String), Transaction(String),
/// 内部通用错误。
Internal(&'static str), Internal(&'static str),
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
impl AppError { impl AppError {
/// 记录并包装数据库连接错误。
pub fn db_conn(e: impl std::fmt::Debug) -> Self { pub fn db_conn(e: impl std::fmt::Debug) -> Self {
tracing::error!("DB connection failed: {e:?}"); tracing::error!("DB connection failed: {e:?}");
AppError::DbConn(format!("{e:?}")) AppError::DbConn(format!("{e:?}"))
} }
/// 记录并包装 SQL 查询错误。
pub fn query(e: impl std::fmt::Debug) -> Self { pub fn query(e: impl std::fmt::Debug) -> Self {
tracing::error!("Query failed: {e:?}"); tracing::error!("Query failed: {e:?}");
AppError::Query(format!("{e:?}")) AppError::Query(format!("{e:?}"))
} }
/// 记录并包装数据库事务错误。
pub fn tx(e: impl std::fmt::Debug) -> Self { pub fn tx(e: impl std::fmt::Debug) -> Self {
tracing::error!("Transaction failed: {e:?}"); tracing::error!("Transaction failed: {e:?}");
AppError::Transaction(format!("{e:?}")) AppError::Transaction(format!("{e:?}"))
} }
} }
/// 转换为 `ServerFnError`,对数据库类错误返回通用中文提示。
impl From<AppError> for ServerFnError { impl From<AppError> for ServerFnError {
fn from(err: AppError) -> ServerFnError { fn from(err: AppError) -> ServerFnError {
let msg = match &err { let msg = match &err {

View File

@ -1,3 +1,10 @@
//! 图片服务的 Axum 处理器与处理流水线。
//!
//! 支持按宽度/高度、缩略图、旋转角度、输出格式/质量动态处理图片,
//! 使用内存moka+ 磁盘两级缓存加速响应。
//! WebP 编解码走 `zenwebp``image` crate 未启用 WebP feature
//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
#[cfg(feature = "server")] #[cfg(feature = "server")]
use axum::{ use axum::{
extract::{Path, Query}, extract::{Path, Query},
@ -16,10 +23,12 @@ const MAX_IMAGE_DIMENSION: u32 = 4096;
#[cfg(feature = "server")] #[cfg(feature = "server")]
const DEFAULT_JPEG_QUALITY: u8 = 85; const DEFAULT_JPEG_QUALITY: u8 = 85;
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 允许处理的最大图片像素数(约 10k x 10k
pub const MAX_IMAGE_PIXELS: u32 = 100_000_000; // ~10k x 10k pub const MAX_IMAGE_PIXELS: u32 = 100_000_000; // ~10k x 10k
#[cfg(feature = "server")] #[cfg(feature = "server")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// 缓存条目,保存处理后的图片字节与 Content-Type。
struct CachedImage { struct CachedImage {
data: Vec<u8>, data: Vec<u8>,
content_type: HeaderValue, content_type: HeaderValue,
@ -35,12 +44,19 @@ static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
#[cfg(feature = "server")] #[cfg(feature = "server")]
#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)] #[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
/// 图片处理查询参数。
pub struct ImageParams { pub struct ImageParams {
/// 限制最大宽度。
pub w: Option<u32>, pub w: Option<u32>,
/// 限制最大高度。
pub h: Option<u32>, pub h: Option<u32>,
/// 缩略图尺寸,格式 `WxH`。
pub thumb: Option<String>, pub thumb: Option<String>,
/// 旋转角度,仅允许 0/90/180/270。
pub rotate: Option<u16>, pub rotate: Option<u16>,
/// 输出格式:`jpeg`/`jpg`、`png`、`webp`。
pub format: Option<String>, pub format: Option<String>,
/// 输出质量,范围 1-100。
pub quality: Option<u8>, pub quality: Option<u8>,
} }
@ -78,6 +94,7 @@ impl ImageParams {
parts.join("|") parts.join("|")
} }
/// 校验参数合法性,返回 HTTP 400 状态码表示非法。
fn validate(&self) -> Result<(), StatusCode> { fn validate(&self) -> Result<(), StatusCode> {
if let Some(dim) = self.w { if let Some(dim) = self.w {
if dim == 0 || dim > MAX_IMAGE_DIMENSION { if dim == 0 || dim > MAX_IMAGE_DIMENSION {
@ -279,6 +296,10 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 图片访问与动态处理的 Axum handler。
///
/// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 →
/// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。
pub async fn serve_image( pub async fn serve_image(
Path(path): Path<String>, Path(path): Path<String>,
Query(params): Query<ImageParams>, Query(params): Query<ImageParams>,
@ -344,6 +365,7 @@ pub async fn serve_image(
Err(_) => return StatusCode::NOT_FOUND.into_response(), Err(_) => return StatusCode::NOT_FOUND.into_response(),
}; };
// WebP 解码使用 zenwebp其它格式使用 image crate。
let original_format = detect_format(&path); let original_format = detect_format(&path);
let img = if original_format == image::ImageFormat::WebP { let img = if original_format == image::ImageFormat::WebP {
match crate::webp::decode(&data) { match crate::webp::decode(&data) {

View File

@ -1,24 +1,36 @@
//! Markdown 渲染与目录生成。
//!
//! 使用 pulldown-cmark 解析 Markdown为标题生成锚点与目录TOC
//! 代码块调用 `highlight` 模块进行语法高亮,最终通过 sanitizer 清理 HTML。
//! 仅在 `feature = "server"` 时执行实际渲染。
#![allow(clippy::unused_unit, deprecated)] #![allow(clippy::unused_unit, deprecated)]
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。
pub fn clean_html(input: &str) -> String { pub fn clean_html(input: &str) -> String {
crate::api::sanitizer::clean_html(input) crate::api::sanitizer::clean_html(input)
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// Markdown 渲染结果。
pub struct RenderedContent { pub struct RenderedContent {
/// 清理后的正文 HTML。
pub html: String, pub html: String,
/// 目录 HTML无标题时为空字符串
pub toc_html: String, pub toc_html: String,
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。
pub fn render_markdown_enhanced(md: &str) -> RenderedContent { pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd}; use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
// 1. Parse markdown and collect headings for TOC // 1. Parse markdown and collect headings for TOC
let parser = pulldown_cmark::Parser::new_ext(md, Options::all()); 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; let mut current_heading: Option<(u8, String)> = None;
for event in parser { for event in parser {
@ -70,6 +82,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
for event in parser { for event in parser {
match event { match event {
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => {
// 先把累积的普通事件刷入 HTML再开始新标题。
if !non_heading_events.is_empty() { if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new(); non_heading_events = Vec::new();
@ -108,6 +121,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
in_heading = false; in_heading = false;
} }
Event::Start(Tag::CodeBlock(kind)) => { Event::Start(Tag::CodeBlock(kind)) => {
// 代码块开始前同样先刷入未处理的普通事件。
if !non_heading_events.is_empty() { if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new(); non_heading_events = Vec::new();
@ -129,6 +143,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
code_buffer.push_str(&text); code_buffer.push_str(&text);
} }
Event::End(TagEnd::CodeBlock) => { Event::End(TagEnd::CodeBlock) => {
// 使用 syntect 对代码块进行服务端语法高亮。
let highlighted = let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
html.push_str("<pre><code"); html.push_str("<pre><code");
@ -142,6 +157,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
} }
_ => { _ => {
if in_heading { if in_heading {
// 标题内部只保留文本与行内代码,避免嵌套块级元素。
match event { match event {
Event::Text(text) => html.push_str(&clean_html(&text)), Event::Text(text) => html.push_str(&clean_html(&text)),
Event::Code(code) => { Event::Code(code) => {
@ -170,6 +186,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 根据标题层级生成嵌套目录 HTML。
fn generate_toc_html(headings: &[(u8, String, String)]) -> String { fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
if headings.is_empty() { if headings.is_empty() {
return String::new(); return String::new();
@ -184,13 +201,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
if i > 0 { if i > 0 {
let prev_level = headings[i - 1].0; let prev_level = headings[i - 1].0;
if level > prev_level { if level > prev_level {
// Open new nested lists // 标题层级升高:打开新的嵌套列表。
for _ in prev_level..level { for _ in prev_level..level {
html.push_str("<ul>"); html.push_str("<ul>");
stack.push(level); stack.push(level);
} }
} else if level < prev_level { } else if level < prev_level {
// Close nested lists // 标题层级降低:关闭多余的嵌套列表。
while let Some(top) = stack.last() { while let Some(top) = stack.last() {
if *top > level { if *top > level {
html.push_str("</li></ul>"); html.push_str("</li></ul>");
@ -213,6 +230,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
} }
// Close remaining lists // Close remaining lists
// 闭合所有残留的嵌套列表。
while stack.len() > 1 { while stack.len() > 1 {
html.push_str("</li></ul>"); html.push_str("</li></ul>");
stack.pop(); stack.pop();
@ -223,6 +241,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 将标题文本转换为可用于锚点的 slug。
fn slugify_heading(text: &str) -> String { fn slugify_heading(text: &str) -> String {
let mut slug = String::new(); let mut slug = String::new();
let mut prev_dash = true; let mut prev_dash = true;

View File

@ -1,10 +1,26 @@
//! API 层根模块。
//!
//! 按职责划分子模块,包含两类接口:
//! - Dioxus server function`#[server(Name, "/api")]`),如 `auth`、`posts`
//! - Axum 手动路由处理器,如 `upload`、`image`。
/// 认证相关的 Dioxus server function。
pub mod auth; pub mod auth;
/// 评论相关接口。
pub mod comments; pub mod comments;
/// 应用错误类型与转换。
pub mod error; pub mod error;
/// 图片服务的 Axum 处理器。
pub mod image; pub mod image;
/// Markdown 渲染与 HTML 清理。
pub mod markdown; pub mod markdown;
/// 文章 CRUD 相关接口。
pub mod posts; pub mod posts;
/// 限流工具。
pub mod rate_limit; pub mod rate_limit;
/// HTML 消毒器。
pub mod sanitizer; pub mod sanitizer;
/// URL slug 生成与校验。
pub mod slug; pub mod slug;
/// 图片上传的 Axum 处理器。
pub mod upload; pub mod upload;

View File

@ -1,3 +1,10 @@
//! 基于 governor 的多级限流。
//!
//! 提供 strict、upload、image、comment 四个限流器,
//! 支持从 `X-Forwarded-For` / `X-Real-IP` 中提取客户端 IP
//! 并可通过 `TRUSTED_PROXY_COUNT` 配置信任代理层数。
//! 仅在 `feature = "server"` 时生效。
#[cfg(feature = "server")] #[cfg(feature = "server")]
use axum::http::StatusCode; use axum::http::StatusCode;
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -49,6 +56,7 @@ static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::ne
}); });
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 检查评论请求是否超出限流阈值。
pub fn check_comment_limit(ip: &str) -> Result<(), String> { pub fn check_comment_limit(ip: &str) -> Result<(), String> {
COMMENT_LIMITER COMMENT_LIMITER
.check_key(&ip.to_string()) .check_key(&ip.to_string())
@ -57,6 +65,7 @@ pub fn check_comment_limit(ip: &str) -> Result<(), String> {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 检查图片访问请求是否超出限流阈值,返回 HTTP 状态码。
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> { pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
IMAGE_LIMITER IMAGE_LIMITER
.check_key(&ip.to_string()) .check_key(&ip.to_string())
@ -74,6 +83,7 @@ fn trusted_proxy_count() -> usize {
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<String> { fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<String> {
// 按逗号拆分并过滤空项,列表末尾是离服务端最近的代理。
let parts: Vec<&str> = value let parts: Vec<&str> = value
.split(',') .split(',')
.map(str::trim) .map(str::trim)
@ -82,14 +92,17 @@ fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<St
if parts.is_empty() || trusted_proxy_count == 0 { if parts.is_empty() || trusted_proxy_count == 0 {
return None; return None;
} }
// 可信任代理数量不足时无法确定真实客户端 IP。
if parts.len() <= trusted_proxy_count { if parts.len() <= trusted_proxy_count {
return None; return None;
} }
// 从列表末尾倒数 `trusted_proxy_count + 1` 位即为真实客户端 IP。
let idx = parts.len() - 1 - trusted_proxy_count; let idx = parts.len() - 1 - trusted_proxy_count;
parts.get(idx).map(|s| s.to_string()) parts.get(idx).map(|s| s.to_string())
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 根据信任代理层数从请求头中提取客户端 IP。
pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count: usize) -> String { pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count: usize) -> String {
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { 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) { 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 trusted_proxy_count > 0 {
if let Some(ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { if let Some(ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
return ip.trim().to_string(); 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")] #[cfg(feature = "server")]
/// 使用环境变量配置的代理层数提取客户端 IP。
pub fn get_client_ip(headers: &http::HeaderMap) -> String { pub fn get_client_ip(headers: &http::HeaderMap) -> String {
get_client_ip_with_trusted(headers, trusted_proxy_count()) get_client_ip_with_trusted(headers, trusted_proxy_count())
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 检查严格限流(注册、登录等敏感接口)。
pub fn check_strict_limit(ip: &str) -> Result<(), String> { pub fn check_strict_limit(ip: &str) -> Result<(), String> {
STRICT_LIMITER STRICT_LIMITER
.check_key(&ip.to_string()) .check_key(&ip.to_string())
@ -120,6 +136,7 @@ pub fn check_strict_limit(ip: &str) -> Result<(), String> {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 检查上传请求是否超出限流阈值。
pub fn check_upload_limit(ip: &str) -> Result<(), String> { pub fn check_upload_limit(ip: &str) -> Result<(), String> {
UPLOAD_LIMITER UPLOAD_LIMITER
.check_key(&ip.to_string()) .check_key(&ip.to_string())

View File

@ -1,3 +1,9 @@
//! HTML 消毒器。
//!
//! 基于 lol_html 清理不受信任的 HTML限制允许的 tag/attribute/URL scheme
//! 分别提供文章正文(`clean_html`)与评论(`clean_comment_html`)两套白名单策略。
//! 仅在 `feature = "server"` 时执行。
#![allow(clippy::unused_unit, deprecated)] #![allow(clippy::unused_unit, deprecated)]
#[cfg(feature = "server")] #[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() { if trimmed.is_empty() {
return true; return true;
} }
// 解析 scheme 并与白名单对比data URI 与 javascript/vbscript 单独处理。
if let Some(colon_pos) = trimmed.find(':') { if let Some(colon_pos) = trimmed.find(':') {
let scheme = &trimmed[..colon_pos]; let scheme = &trimmed[..colon_pos];
let scheme_lower = scheme.to_lowercase(); 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")] #[cfg(feature = "server")]
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
struct SanitizerConfig { struct SanitizerConfig {
allowed_tags: HashSet<&'static str>, allowed_tags: HashSet<&'static str>,
extra_generic_attrs: Vec<&'static str>, extra_generic_attrs: Vec<&'static str>,
@ -251,6 +259,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
.filter_map(|attr| { .filter_map(|attr| {
let name = attr.name(); let name = attr.name();
let name_lower = name.to_lowercase(); let name_lower = name.to_lowercase();
// 仅保留白名单属性;对 href/src/cite 额外校验 URL 安全性。
if allowed_for_tag.contains(name_lower.as_str()) { if allowed_for_tag.contains(name_lower.as_str()) {
if name_lower == "href" || name_lower == "src" || name_lower == "cite" { if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
let val = attr.value(); let val = attr.value();
@ -296,6 +305,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 文章正文 HTML 清理:允许较完整的标签与 data URI外链添加 `noopener noreferrer`。
pub fn clean_html(input: &str) -> String { pub fn clean_html(input: &str) -> String {
let config = SanitizerConfig { let config = SanitizerConfig {
allowed_tags: default_allowed_tags(), allowed_tags: default_allowed_tags(),
@ -327,6 +337,7 @@ pub fn clean_html(input: &str) -> String {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI外链添加 `nofollow noopener`。
pub fn clean_comment_html(input: &str) -> String { pub fn clean_comment_html(input: &str) -> String {
let mut tags = default_allowed_tags(); let mut tags = default_allowed_tags();
tags.remove("img"); tags.remove("img");

View File

@ -1,8 +1,18 @@
//! 文章 slug 生成与唯一性校验。
//!
//! 将标题转换为小写、仅含字母数字与连字符/下划线的 URL 友好形式,
//! 并检测数据库中是否已存在,必要时追加数字后缀。
//! 仅在 `feature = "server"` 时访问数据库。
#![allow(clippy::unused_unit, deprecated)] #![allow(clippy::unused_unit, deprecated)]
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 将标题转换为 URL 友好的 slug。
///
/// 非字母数字字符替换为 `-` 并合并连续 `-`,结果截断至 100 字符;
/// 若全部字符被过滤,则返回当前时间戳作为 slug。
pub fn slugify(title: &str) -> String { pub fn slugify(title: &str) -> String {
let slug: String = title let slug: String = title
.to_lowercase() .to_lowercase()
@ -16,6 +26,7 @@ pub fn slugify(title: &str) -> String {
}) })
.collect(); .collect();
// 合并连续的连字符,并去除首尾空段。
let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect(); let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect();
let slug = parts.join("-"); let slug = parts.join("-");
@ -27,6 +38,7 @@ pub fn slugify(title: &str) -> String {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 校验 slug 是否为空且仅含合法字符、长度不超过 200。
pub fn is_valid_slug(slug: &str) -> bool { pub fn is_valid_slug(slug: &str) -> bool {
if slug.is_empty() || slug.len() > 200 { if slug.is_empty() || slug.len() > 200 {
return false; return false;
@ -36,6 +48,10 @@ pub fn is_valid_slug(slug: &str) -> bool {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 确保生成的 slug 在数据库中唯一。
///
/// 若 `exclude_id` 不为空,则排除该文章自身;
/// 当冲突时依次尝试 `base-2`、`base-3` …… 直到生成唯一值。
pub async fn ensure_unique_slug( pub async fn ensure_unique_slug(
client: &tokio_postgres::Client, client: &tokio_postgres::Client,
base: &str, base: &str,
@ -47,6 +63,7 @@ pub async fn ensure_unique_slug(
let mut suffix = 2; let mut suffix = 2;
loop { loop {
// 查询当前候选 slug 是否已存在(排除指定文章 ID
let exists = if let Some(exclude) = exclude_id { let exists = if let Some(exclude) = exclude_id {
client client
.query_opt( .query_opt(
@ -74,6 +91,7 @@ pub async fn ensure_unique_slug(
candidate = format!("{}-{}", base, suffix); candidate = format!("{}-{}", base, suffix);
suffix += 1; suffix += 1;
// 防止无限循环slug 总长度超过 200 时直接报错。
if candidate.len() > 200 { if candidate.len() > 200 {
return Err(AppError::Internal("无法生成唯一 slug").into()); return Err(AppError::Internal("无法生成唯一 slug").into());
} }

View File

@ -1,3 +1,10 @@
//! 图片上传的 Axum 处理器。
//!
//! 处理 multipart 上传,校验 MIME 类型、文件大小与 admin 权限,
//! JPEG/PNG 自动转 WebP若体积更小则保留原格式GIF/WebP 保持原样。
//! 文件按日期分目录存放于 `uploads/`。
//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
#[cfg(feature = "server")] #[cfg(feature = "server")]
use axum::{ use axum::{
extract::Multipart, extract::Multipart,
@ -27,6 +34,10 @@ fn mime_to_ext(mime: &str) -> &'static str {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 处理图片上传的 Axum handler。
///
/// 流程:限流 → 解析 session → 校验 admin → 读取 multipart → 校验类型/大小 →
/// 转码(如适用)→ 按日期落盘 → 返回相对 URL。
pub async fn upload_image( pub async fn upload_image(
headers: HeaderMap, headers: HeaderMap,
mut multipart: Multipart, mut multipart: Multipart,
@ -150,6 +161,7 @@ pub async fn upload_image(
let is_gif = mime_type.as_str() == "image/gif"; let is_gif = mime_type.as_str() == "image/gif";
let is_webp = mime_type.as_str() == "image/webp"; let is_webp = mime_type.as_str() == "image/webp";
// GIF 与 WebP 保持原格式;其余格式尝试转 WebP。
let (final_data, final_ext) = if is_gif { let (final_data, final_ext) = if is_gif {
(data.to_vec(), "gif".to_string()) (data.to_vec(), "gif".to_string())
} else if is_webp { } else if is_webp {
@ -158,6 +170,7 @@ pub async fn upload_image(
let original_data = data.to_vec(); let original_data = data.to_vec();
let mime = mime_type.clone(); let mime = mime_type.clone();
let config = crate::webp::WEBP_CONFIG.clone(); let config = crate::webp::WEBP_CONFIG.clone();
// 在阻塞线程中执行图片解码与 WebP 编码,避免阻塞异步运行时。
let result = tokio::task::spawn_blocking(move || -> (Vec<u8>, String, bool) { let result = tokio::task::spawn_blocking(move || -> (Vec<u8>, String, bool) {
let total_start = std::time::Instant::now(); let total_start = std::time::Instant::now();
match image::load_from_memory(&original_data) { match image::load_from_memory(&original_data) {
@ -168,6 +181,7 @@ pub async fn upload_image(
Ok(webp_data) => { Ok(webp_data) => {
let enc_time = enc_start.elapsed(); let enc_time = enc_start.elapsed();
let total_time = total_start.elapsed(); let total_time = total_start.elapsed();
// WebP 更小才采用,否则回退原格式以节省带宽。
if webp_data.len() < original_data.len() { if webp_data.len() < original_data.len() {
tracing::info!( tracing::info!(
"WebP conversion: decode={:?} encode={:?} total={:?} {}x{} {} bytes -> {} bytes", "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 now = chrono::Utc::now();
let year = now.format("%Y").to_string(); let year = now.format("%Y").to_string();
let month = now.format("%m").to_string(); let month = now.format("%m").to_string();

View File

@ -1,2 +1,9 @@
//! 认证辅助模块。
//!
//! 包含密码哈希Argon2与会话 token 管理两个子模块。
/// 密码哈希Argon2子模块。
pub mod password; pub mod password;
/// 会话 token 管理子模块。
pub mod session; pub mod session;

View File

@ -1,3 +1,8 @@
//! 密码哈希与校验Argon2
//!
//! 使用随机 salt 生成 PHC 字符串格式哈希,并通过 Argon2 验证。
//! `#[allow(dead_code)]` 用于避免 WASM 构建中服务端函数体被剥离后的未使用警告。
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Argon2,
@ -5,6 +10,7 @@ use argon2::{
use rand::rngs::OsRng; use rand::rngs::OsRng;
#[allow(dead_code)] #[allow(dead_code)]
/// 使用 Argon2 对明文密码进行哈希。
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); let argon2 = Argon2::default();
@ -13,6 +19,7 @@ pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Er
} }
#[allow(dead_code)] #[allow(dead_code)]
/// 校验明文密码是否与已存储的哈希匹配。
pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> { pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
let parsed_hash = PasswordHash::new(hash)?; let parsed_hash = PasswordHash::new(hash)?;
let argon2 = Argon2::default(); let argon2 = Argon2::default();

View File

@ -1,13 +1,21 @@
//! 会话 token 生成、哈希与 Cookie 处理。
//!
//! token 使用 UUID存储时使用 SHA-256 哈希,
//! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。
//! 服务端上下文解析函数仅在 `feature = "server"` 时可用。
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
#[allow(dead_code)] #[allow(dead_code)]
/// 生成新的随机会话 tokenUUID 格式)。
pub fn generate_token() -> String { pub fn generate_token() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
#[allow(dead_code)] #[allow(dead_code)]
/// 使用 SHA-256 对 token 进行哈希,用于数据库存储。
pub fn hash_token(token: &str) -> String { pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
@ -15,11 +23,13 @@ pub fn hash_token(token: &str) -> String {
} }
#[allow(dead_code)] #[allow(dead_code)]
/// 返回默认会话过期时间(当前时间 + 30 天)。
pub fn default_expiry() -> DateTime<Utc> { pub fn default_expiry() -> DateTime<Utc> {
Utc::now() + Duration::days(30) Utc::now() + Duration::days(30)
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 读取环境变量 `COOKIE_SECURE`,决定是否给 Cookie 添加 Secure 标志。
pub fn cookie_secure() -> bool { pub fn cookie_secure() -> bool {
std::env::var("COOKIE_SECURE") std::env::var("COOKIE_SECURE")
.ok() .ok()
@ -28,6 +38,7 @@ pub fn cookie_secure() -> bool {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 构造会话 Cookie 字符串,包含 HttpOnly、Path、Max-Age 与 SameSite。
pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String { pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String {
let secure_flag = if secure { "; Secure" } else { "" }; let secure_flag = if secure { "; Secure" } else { "" };
format!( format!(
@ -36,6 +47,7 @@ pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 从 `Cookie` 头中解析名为 `session` 的 token 值。
pub fn parse_session_token(cookie_header: &str) -> Option<&str> { pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
cookie_header.split(';').map(|s| s.trim()).find_map(|pair| { cookie_header.split(';').map(|s| s.trim()).find_map(|pair| {
let mut parts = pair.splitn(2, '='); let mut parts = pair.splitn(2, '=');
@ -50,6 +62,7 @@ pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 从 Dioxus `FullstackContext` 中读取 Cookie 并返回会话 token。
pub fn get_session_from_ctx() -> Option<String> { pub fn get_session_from_ctx() -> Option<String> {
use dioxus::fullstack::FullstackContext; use dioxus::fullstack::FullstackContext;