From 449a5458861639d1d71bd386b82e33e886d06ece Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 10:34:14 +0800 Subject: [PATCH] security: fix critical issues from repository review P0 blockers: - Fix migration numbering conflict and duplicate indexes - Change comments.post_id FK to ON DELETE CASCADE - Restrict public post detail endpoint to published posts only - Fix rate-limiting IP extraction and fallback to ConnectInfo - Harden HTML sanitizer: deny unknown URL schemes, restrict data URIs - Remove session token from login response body - Enforce image pixel/dimension limits on upload and serving P1 high-risk: - Validate uploads by magic bytes and decode GIF/WebP - Add pagination/rate-limiting to search, tag posts, and comments - Make first-admin registration and slug uniqueness check atomic - HTML-escape comment author fields - Improve HTML minify cache key and skip admin/error responses - Add mobile navigation menu P2 accessibility/quality: - Associate form labels with inputs - Key PostDetail article by slug to re-init scripts on navigation - Improve image viewer keyboard accessibility - Make theme toggle SSR-friendly and add aria-label - Invalidate slug 404 cache on create and pending count on new comment - Deduplicate tags case-insensitively P3 cleanup: - Remove unused tower-http dependency, expand make clean - Configure DB pool timeouts and verified recycling - Run background cleanup tasks immediately on startup - Use SHA-256 for stable disk cache keys - Log DB errors with Display instead of Debug - Update README migration instructions All tests pass (321), clippy clean, dx check clean. --- Cargo.lock | 1 - Cargo.toml | 3 +- Makefile | 4 +- README.md | 4 +- migrations/002_indexes.sql | 15 -- migrations/002_posts.sql | 6 +- migrations/003_indexes.sql | 10 + ...03_search_trgm.sql => 004_search_trgm.sql} | 0 .../{004_comments.sql => 005_comments.sql} | 2 +- ..._add_toc_html.sql => 006_add_toc_html.sql} | 0 .../{006_settings.sql => 007_settings.sql} | 0 migrations/008_comments_cascade.sql | 9 + src/api/auth.rs | 72 ++++--- src/api/comments/create.rs | 17 +- src/api/comments/helpers.rs | 30 ++- src/api/comments/read.rs | 4 +- src/api/error.rs | 20 +- src/api/image.rs | 68 ++++-- src/api/markdown.rs | 7 +- src/api/posts/create.rs | 7 +- src/api/posts/helpers.rs | 13 +- src/api/posts/list.rs | 3 +- src/api/posts/read.rs | 2 +- src/api/posts/search.rs | 28 ++- src/api/posts/trash.rs | 28 ++- src/api/posts/update.rs | 87 ++++---- src/api/rate_limit.rs | 195 +++++++++++++++--- src/api/sanitizer.rs | 67 ++++-- src/api/slug.rs | 35 ++-- src/api/upload.rs | 103 ++++++++- src/bin/generate_highlight_css.rs | 3 +- src/components/forms.rs | 10 +- src/components/header.rs | 97 ++++++++- src/components/image_viewer.rs | 42 ++++ src/db/pool.rs | 7 +- src/main.rs | 5 +- src/middleware/minify_html.rs | 27 ++- src/pages/login.rs | 19 +- src/pages/post_detail.rs | 2 +- src/pages/register.rs | 12 +- src/tasks/ip_purge.rs | 2 +- src/tasks/post_purge.rs | 2 +- src/tasks/session_cleanup.rs | 2 +- src/theme.rs | 9 +- 44 files changed, 810 insertions(+), 269 deletions(-) delete mode 100644 migrations/002_indexes.sql create mode 100644 migrations/003_indexes.sql rename migrations/{003_search_trgm.sql => 004_search_trgm.sql} (100%) rename migrations/{004_comments.sql => 005_comments.sql} (96%) rename migrations/{005_add_toc_html.sql => 006_add_toc_html.sql} (100%) rename migrations/{006_settings.sql => 007_settings.sql} (100%) create mode 100644 migrations/008_comments_cascade.sql diff --git a/Cargo.lock b/Cargo.lock index 26d0e94..dd86245 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5328,7 +5328,6 @@ dependencies = [ "syntect", "tokio", "tokio-postgres", - "tower-http", "tracing", "tracing-subscriber", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 2e337f2..e62cbf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ pulldown-cmark = { version = "0.13", optional = true } dotenvy = { version = "0.15", optional = true } tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] } tracing-subscriber = { version = "0.3", optional = true } -tower-http = { version = "0.6", optional = true } + rand = { version = "0.8", features = ["getrandom"], optional = true } http = { version = "1", optional = true } axum = { version = "0.8", optional = true, features = ["multipart"] } @@ -72,7 +72,6 @@ server = [ "dep:dotenvy", "dep:tracing", "dep:tracing-subscriber", - "dep:tower-http", "dep:http-body-util", "dep:lol_html", "dep:syntect", diff --git a/Makefile b/Makefile index 4fecf0c..2710fc3 100644 --- a/Makefile +++ b/Makefile @@ -39,4 +39,6 @@ test: clean: @cargo clean - @rm -f public/style.css + @rm -f public/style.css public/highlight.css + @rm -rf public/tiptap + @rm -rf uploads/.cache diff --git a/README.md b/README.md index 3964c23..ad164d0 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ # 配置数据库 DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil -# 运行迁移 -psql $DATABASE_URL -f migrations/001_init.sql +# 运行迁移(自动创建数据库并按顺序执行 migrations/ 下所有 SQL) +./migrate.sh # 启动开发服务器 make dev diff --git a/migrations/002_indexes.sql b/migrations/002_indexes.sql deleted file mode 100644 index 9ac7d02..0000000 --- a/migrations/002_indexes.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 按 slug 查询文章(文章详情页) -CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug) WHERE deleted_at IS NULL; - --- 按状态和时间查询(文章列表、归档) -CREATE INDEX IF NOT EXISTS idx_posts_status_published ON posts(status, deleted_at, published_at DESC); - --- 标签名称查询 -CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); - --- 文章标签关联查询 -CREATE INDEX IF NOT EXISTS idx_post_tags_post_id ON post_tags(post_id); -CREATE INDEX IF NOT EXISTS idx_post_tags_tag_id ON post_tags(tag_id); - --- 用户会话查询 -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); diff --git a/migrations/002_posts.sql b/migrations/002_posts.sql index 2b7a589..865bb6b 100644 --- a/migrations/002_posts.sql +++ b/migrations/002_posts.sql @@ -1,4 +1,4 @@ -CREATE TABLE posts ( +CREATE TABLE IF NOT EXISTS posts ( id SERIAL PRIMARY KEY, author_id INT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, @@ -24,12 +24,12 @@ CREATE INDEX idx_posts_status_published ON posts(status, published_at DESC) WHER CREATE INDEX idx_posts_slug ON posts(slug) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL; -CREATE TABLE tags ( +CREATE TABLE IF NOT EXISTS tags ( id SERIAL PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL ); -CREATE TABLE post_tags ( +CREATE TABLE IF NOT EXISTS post_tags ( post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (post_id, tag_id) diff --git a/migrations/003_indexes.sql b/migrations/003_indexes.sql new file mode 100644 index 0000000..359943a --- /dev/null +++ b/migrations/003_indexes.sql @@ -0,0 +1,10 @@ +-- 补充索引(已在 002_posts.sql 中创建的索引不再重复定义) + +-- 标签名称查询 +CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); + +-- 文章标签关联查询(tag 方向) +CREATE INDEX IF NOT EXISTS idx_post_tags_tag_id ON post_tags(tag_id); + +-- 用户会话查询 +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); diff --git a/migrations/003_search_trgm.sql b/migrations/004_search_trgm.sql similarity index 100% rename from migrations/003_search_trgm.sql rename to migrations/004_search_trgm.sql diff --git a/migrations/004_comments.sql b/migrations/005_comments.sql similarity index 96% rename from migrations/004_comments.sql rename to migrations/005_comments.sql index c46db6e..e85d6cb 100644 --- a/migrations/004_comments.sql +++ b/migrations/005_comments.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS comments ( id BIGSERIAL PRIMARY KEY, - post_id INT NOT NULL REFERENCES posts(id) ON DELETE RESTRICT, + post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, parent_id BIGINT REFERENCES comments(id) ON DELETE SET NULL, depth INT NOT NULL DEFAULT 0, author_name VARCHAR(50) NOT NULL, diff --git a/migrations/005_add_toc_html.sql b/migrations/006_add_toc_html.sql similarity index 100% rename from migrations/005_add_toc_html.sql rename to migrations/006_add_toc_html.sql diff --git a/migrations/006_settings.sql b/migrations/007_settings.sql similarity index 100% rename from migrations/006_settings.sql rename to migrations/007_settings.sql diff --git a/migrations/008_comments_cascade.sql b/migrations/008_comments_cascade.sql new file mode 100644 index 0000000..4a468b8 --- /dev/null +++ b/migrations/008_comments_cascade.sql @@ -0,0 +1,9 @@ +-- 将 comments.post_id 外键从 RESTRICT 改为 CASCADE, +-- 使回收站清理/自动清理能够删除仍有评论的文章。 + +ALTER TABLE comments + DROP CONSTRAINT IF EXISTS comments_post_id_fkey; + +ALTER TABLE comments + ADD CONSTRAINT comments_post_id_fkey + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; diff --git a/src/api/auth.rs b/src/api/auth.rs index fbf9623..bd24926 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -113,50 +113,48 @@ 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 - .map_err(AppError::query)? - .get(0); + let password_hash = + password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?; - if admin_count > 0 { + // 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。 + // 若已有 admin 或用户名/邮箱冲突,RETURNING 将返回空。 + let result = client + .query_opt( + "INSERT INTO users (username, email, password_hash, role) + VALUES ($1, $2, $3, 'admin') + ON CONFLICT DO NOTHING + RETURNING id", + &[&username, &email, &password_hash], + ) + .await + .map_err(AppError::query)?; + + if result.is_some() { return Ok(AuthResponse { - success: false, - message: "Registration is closed".to_string(), + success: true, + message: "注册成功".to_string(), token: None, }); } - let password_hash = - password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?; + // 插入失败:区分是已有 admin 还是用户名/邮箱冲突。 + let admin_exists: bool = client + .query_one("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')", &[]) + .await + .map_err(AppError::query)? + .get(0); - let result = client - .query_one( - "INSERT INTO users (username, email, password_hash, role) VALUES ($1, $2, $3, 'admin') RETURNING id", - &[&username, &email, &password_hash], - ) - .await; + let message = if admin_exists { + "Registration is closed".to_string() + } else { + "用户名或邮箱已存在".to_string() + }; - match result { - Ok(_) => Ok(AuthResponse { - success: true, - message: "注册成功".to_string(), - token: None, - }), - Err(e) => { - let msg = if e.to_string().contains("unique constraint") { - "用户名或邮箱已存在".to_string() - } else { - format!("注册失败: {}", e) - }; - Ok(AuthResponse { - success: false, - message: msg, - token: None, - }) - } - } + Ok(AuthResponse { + success: false, + message, + token: None, + }) } /// 用户登录。 @@ -271,7 +269,7 @@ pub async fn login(username: String, password: String) -> Result 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 { @@ -108,7 +119,8 @@ pub fn validate_comment_email(email: &str) -> Result<(), String> { Ok(()) } -/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头且不超过 200 字符。 +/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头、 +/// 不含 HTML 特殊字符与空白,且不超过 200 字符。 #[cfg(feature = "server")] pub fn validate_comment_url(url: &str) -> Result<(), String> { let trimmed = url.trim(); @@ -122,6 +134,10 @@ pub fn validate_comment_url(url: &str) -> Result<(), 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(()) } @@ -162,6 +178,18 @@ 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/comments/read.rs b/src/api/comments/read.rs index 67907f3..34dab9e 100644 --- a/src/api/comments/read.rs +++ b/src/api/comments/read.rs @@ -33,7 +33,9 @@ pub async fn get_comments(post_id: i32) -> Result Self { - tracing::error!("DB connection failed: {e:?}"); - AppError::DbConn(format!("{e:?}")) + /// + /// 日志仅记录 Display 摘要,避免 Debug 输出中的 SQL/参数泄露。 + pub fn db_conn(e: impl std::fmt::Display + std::fmt::Debug) -> Self { + tracing::error!("DB connection failed: {e}"); + AppError::DbConn("connection error".to_string()) } /// 记录并包装 SQL 查询错误。 - pub fn query(e: impl std::fmt::Debug) -> Self { - tracing::error!("Query failed: {e:?}"); - AppError::Query(format!("{e:?}")) + pub fn query(e: impl std::fmt::Display + std::fmt::Debug) -> Self { + tracing::error!("Query failed: {e}"); + AppError::Query("query error".to_string()) } /// 记录并包装数据库事务错误。 - pub fn tx(e: impl std::fmt::Debug) -> Self { - tracing::error!("Transaction failed: {e:?}"); - AppError::Transaction(format!("{e:?}")) + pub fn tx(e: impl std::fmt::Display + std::fmt::Debug) -> Self { + tracing::error!("Transaction failed: {e}"); + AppError::Transaction("transaction error".to_string()) } } diff --git a/src/api/image.rs b/src/api/image.rs index f40d201..98a15f5 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -7,11 +7,13 @@ #[cfg(feature = "server")] use axum::{ - extract::{Path, Query}, + extract::{ConnectInfo, Path, Query}, http::{header, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; #[cfg(feature = "server")] +use std::net::SocketAddr; +#[cfg(feature = "server")] use moka::future::Cache; #[cfg(feature = "server")] use serde::Deserialize; @@ -19,12 +21,12 @@ use serde::Deserialize; use std::sync::LazyLock; #[cfg(feature = "server")] -const MAX_IMAGE_DIMENSION: u32 = 4096; +pub 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 +/// 允许处理的最大图片像素数(约 5k x 5k)。 +pub const MAX_IMAGE_PIXELS: u32 = 25_000_000; // ~5k x 5k #[cfg(feature = "server")] #[derive(Debug, Clone)] @@ -163,12 +165,41 @@ fn content_type(format: image::ImageFormat) -> HeaderValue { } } +#[cfg(feature = "server")] +fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> { + if width == 0 || height == 0 { + return Err(StatusCode::BAD_REQUEST); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > u64::from(MAX_IMAGE_PIXELS) { + tracing::warn!( + "Image dimensions too large: {}x{} ({} pixels, max {})", + width, + height, + pixels, + MAX_IMAGE_PIXELS + ); + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + Ok(()) +} + +#[cfg(feature = "server")] +fn image_reader_limits() -> image::Limits { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024); + limits +} + #[cfg(feature = "server")] fn process_image( img: image::DynamicImage, params: &ImageParams, original_format: image::ImageFormat, ) -> Result<(Vec, HeaderValue), StatusCode> { + check_image_dimensions(img.width(), img.height())?; let mut img = img; // Rotate first, then resize @@ -257,11 +288,12 @@ const CACHE_DIR: &str = "uploads/.cache"; #[cfg(feature = "server")] fn disk_cache_base(cache_key: &str) -> String { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - cache_key.hash(&mut hasher); - let hash = hasher.finish(); - format!("{}/cache_{:016x}", CACHE_DIR, hash) + // 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed + // 导致旧缓存无法命中且文件无限累积。 + use sha2::Digest; + let hash = sha2::Sha256::digest(cache_key.as_bytes()); + let hash_hex = hex::encode(hash); + format!("{}/cache_{}", CACHE_DIR, hash_hex) } #[cfg(feature = "server")] @@ -301,11 +333,12 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) { /// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 → /// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。 pub async fn serve_image( + ConnectInfo(addr): ConnectInfo, Path(path): Path, Query(params): Query, headers: HeaderMap, ) -> Response { - let ip = crate::api::rate_limit::get_client_ip(&headers); + let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, Some(addr)); if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) { return status.into_response(); } @@ -369,7 +402,12 @@ pub async fn serve_image( let original_format = detect_format(&path); let img = if original_format == image::ImageFormat::WebP { match crate::webp::decode(&data) { - Ok(img) => img, + Ok(img) => { + if let Err(status) = check_image_dimensions(img.width(), img.height()) { + return status.into_response(); + } + img + } Err(e) => { tracing::warn!("WebP decode failed ({}), returning raw bytes", e); let ct = content_type(original_format); @@ -377,9 +415,13 @@ pub async fn serve_image( } } } else { - match image::load_from_memory_with_format(&data, original_format) { + let cursor = std::io::Cursor::new(&data); + let mut reader = image::ImageReader::with_format(cursor, original_format); + reader.limits(image_reader_limits()); + match reader.decode() { Ok(img) => img, - Err(_) => { + Err(e) => { + tracing::warn!("Image decode failed ({}), returning raw bytes", e); let ct = content_type(original_format); return (StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response(); } diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 3a0324d..92ff537 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -473,11 +473,12 @@ mod tests { } #[test] - fn render_markdown_data_uri_image() { + fn render_markdown_data_uri_image_removed() { let result = render_markdown_enhanced("![alt](data:image/svg+xml,%3csvg%3e%3c/svg%3e)"); + // 出于 XSS 防护,文章正文不再保留 data URI src。 assert!( - result.html.contains("data:image/svg+xml"), - "data URI should be preserved in img src, got: {}", + !result.html.contains("data:image/svg+xml"), + "data URI should be removed from img src, got: {}", result.html ); assert!(result.html.contains("alt=\"alt\"")); diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index 197de9f..d485f27 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -76,8 +76,6 @@ pub async fn create_post( { let mut client = get_conn().await.map_err(AppError::db_conn)?; - // 保证 slug 全局唯一,若冲突则追加数字后缀。 - let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?; // 渲染 Markdown 为 HTML,并提取目录。 let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); let content_html = rendered.html; @@ -102,6 +100,9 @@ pub async fn create_post( let tx = client.transaction().await.map_err(AppError::tx)?; + // 保证 slug 全局唯一,若冲突则追加数字后缀;在事务内检查避免并发竞态。 + let final_slug = crate::api::slug::ensure_unique_slug(&tx, &base_slug, None).await?; + // 插入文章记录。 let row = tx .query_one( @@ -136,6 +137,8 @@ pub async fn create_post( crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_stats(); + // 失效按 slug 缓存,避免之前缓存的 404 继续命中。 + crate::cache::invalidate_post_by_slug(&final_slug).await; // 失效该文章涉及的所有标签缓存。 for tag_name in &tags_cleaned { diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 23aefb0..985ee6a 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -201,14 +201,14 @@ pub(super) async fn sync_tags( Ok(()) } -/// 清洗标签列表:去头尾空白并过滤空字符串。 -/// -/// 注意:该函数保留重复标签,由数据库唯一索引或调用方决定去重。 +/// 清洗标签列表:去头尾空白、过滤空字符串并去重(保留原始顺序)。 #[cfg(feature = "server")] pub(super) fn clean_tags(tags: &[String]) -> Vec { + let mut seen = std::collections::HashSet::new(); tags.iter() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) + .filter(|t| seen.insert(t.to_lowercase())) .collect() } @@ -237,15 +237,16 @@ mod tests { } #[test] - fn clean_tags_preserves_duplicates() { + fn clean_tags_removes_duplicates_case_insensitive() { let input = vec![ "rust".to_string(), " rust ".to_string(), - "rust".to_string(), + "Rust".to_string(), + "wasm".to_string(), ]; assert_eq!( clean_tags(&input), - vec!["rust".to_string(), "rust".to_string(), "rust".to_string()] + vec!["rust".to_string(), "wasm".to_string()] ); } diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index e7e16c9..07b5058 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -261,7 +261,8 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result Result Result { #[cfg(feature = "server")] { + use crate::api::rate_limit; + + // 对搜索接口进行严格限流,防止滥用 expensive 查询。 + if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { + let parts = ctx.parts_mut(); + let ip = rate_limit::get_client_ip(&parts.headers); + if let Err(_msg) = rate_limit::check_strict_limit(&ip) { + return Ok(PostListResponse { + posts: Vec::new(), + total: 0, + }); + } + } + let client = get_conn().await.map_err(AppError::db_conn)?; let q = query.trim(); - if q.is_empty() { + if q.is_empty() || q.chars().count() > 200 { return Ok(PostListResponse { posts: Vec::new(), total: 0, }); } + // 转义 SQL LIKE 通配符,避免用户输入 % / _ 导致全表扫描。 + let escaped = q + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + // 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。 let rows = client .query( @@ -40,16 +60,16 @@ pub async fn search_posts(query: String) -> Result Result Result Result) -> Result) -> Result + } else { + Some(rendered.toc_html) + }; + // 未填写摘要时自动从正文提取。 + let summary = summary + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::utils::text::auto_summary(&content_md)); + let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); + let cover_image = cover_image.filter(|s| !s.trim().is_empty()); + + let tx = client.transaction().await.map_err(AppError::tx)?; + // 查询旧 slug,用于后续缓存失效。 - let old_slug: Option = client + let old_slug: Option = tx .query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id]) .await .map_err(AppError::query)? .map(|r| r.get(0)); // 校验文章存在、未删除且归属当前用户。 - let exists: bool = client + let exists: bool = tx .query_opt( "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", &[&post_id, &user.id], @@ -83,25 +100,9 @@ pub async fn update_post( _ => crate::api::slug::slugify(&title), }; - // 保证 slug 全局唯一,排除当前文章自身。 + // 保证 slug 全局唯一,排除当前文章自身;在事务内检查避免并发竞态。 let final_slug = - crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?; - // 重新渲染 Markdown 与目录。 - let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); - let content_html = rendered.html; - let toc_html = if rendered.toc_html.is_empty() { - None:: - } else { - Some(rendered.toc_html) - }; - // 未填写摘要时自动从正文提取。 - let summary = summary - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| crate::utils::text::auto_summary(&content_md)); - let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); - let cover_image = cover_image.filter(|s| !s.trim().is_empty()); - - let tx = client.transaction().await.map_err(AppError::tx)?; + crate::api::slug::ensure_unique_slug(&tx, &base_slug, Some(post_id)).await?; // 获取文章旧标签,用于后续失效标签缓存。 let old_tags: Vec = { @@ -147,24 +148,34 @@ pub async fn update_post( }; // 更新文章主表。 - tx.execute( - "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW() - WHERE id = $10", - &[ - &title.trim(), - &final_slug, - &summary, - &content_md, - &content_html, - &toc_html, - &post_status.as_str(), - &published_at, - &cover_image, - &post_id, - ], - ) - .await - .map_err(AppError::tx)?; + let updated = tx + .execute( + "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW() + WHERE id = $10", + &[ + &title.trim(), + &final_slug, + &summary, + &content_md, + &content_html, + &toc_html, + &post_status.as_str(), + &published_at, + &cover_image, + &post_id, + ], + ) + .await + .map_err(AppError::tx)?; + + if updated == 0 { + return Ok(CreatePostResponse { + success: false, + message: "文章不存在或无权限".to_string(), + post_id: None, + slug: None, + }); + } let tags_cleaned = clean_tags(&tags); let tags_for_invalidation = tags_cleaned.clone(); diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index d636602..d9856bf 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -3,6 +3,12 @@ //! 提供 strict、upload、image、comment 四个限流器, //! 支持从 `X-Forwarded-For` / `X-Real-IP` 中提取客户端 IP, //! 并可通过 `TRUSTED_PROXY_COUNT` 配置信任代理层数。 +//! +//! 当未配置可信代理时,Axum handler 可回退到 TCP 连接的对端地址; +//! Dioxus server function 无法获取对端地址,会退回到 `"unknown"` key, +//! 此时所有请求共享一个限流桶。生产环境应在反向代理后部署并正确配置 +//! `TRUSTED_PROXY_COUNT`。 +//! //! 仅在 `feature = "server"` 时生效。 #[cfg(feature = "server")] @@ -81,49 +87,99 @@ fn trusted_proxy_count() -> usize { .unwrap_or(0) } +#[cfg(feature = "server")] +fn is_valid_ip(ip: &str) -> bool { + ip.parse::().is_ok() +} + #[cfg(feature = "server")] fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option { - // 按逗号拆分并过滤空项,列表末尾是离服务端最近的代理。 + // X-Forwarded-For 格式:client, proxy1, proxy2, ..., proxyN + // 越靠右的地址离服务端越近。 let parts: Vec<&str> = value .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .collect(); - if parts.is_empty() || trusted_proxy_count == 0 { + + if trusted_proxy_count == 0 || parts.len() <= trusted_proxy_count { return None; } - // 可信任代理数量不足时无法确定真实客户端 IP。 - if parts.len() <= trusted_proxy_count { - return None; - } - // 从列表末尾倒数 `trusted_proxy_count + 1` 位即为真实客户端 IP。 + + // 真实客户端 IP 位于右侧第 trusted_proxy_count + 1 个。 let idx = parts.len() - 1 - trusted_proxy_count; - parts.get(idx).map(|s| s.to_string()) + let ip = parts[idx].to_string(); + if is_valid_ip(&ip) { + Some(ip) + } else { + None + } } #[cfg(feature = "server")] -/// 根据信任代理层数从请求头中提取客户端 IP。 -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(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) { +fn ip_from_x_real_ip(value: &str) -> Option { + let ip = value.trim().to_string(); + if is_valid_ip(&ip) { + Some(ip) + } else { + None + } +} + +#[cfg(feature = "server")] +fn get_client_ip_internal( + headers: &http::HeaderMap, + trusted: usize, + peer: Option, +) -> String { + if trusted > 0 { + 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) { + return ip; + } + } + + if let Some(ip) = headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .and_then(ip_from_x_real_ip) + { return ip; } } - // 配置了信任代理时,回退到 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(); - } + if let Some(addr) = peer { + return addr.ip().to_string(); } + // Server function 等非 Axum 上下文无法获取对端地址,退回到 unknown。 + // 此时所有请求共享一个限流桶,生产环境应在反向代理后部署。 + tracing::warn!( + "无法获取客户端真实 IP(未配置 TRUSTED_PROXY_COUNT 且无法读取 TCP 对端地址),\ + 限流将按 'unknown' 键聚合" + ); "unknown".to_string() } +#[cfg(feature = "server")] +/// 根据信任代理层数从请求头中提取客户端 IP,并校验 IP 合法性。 +/// +/// 当未配置可信代理时,不会信任任何 `X-Forwarded-For` / `X-Real-IP` 头, +/// 而是直接返回 `peer` 中的 TCP 对端地址(如果提供)。 +pub fn get_client_ip_with_peer( + headers: &http::HeaderMap, + peer: Option, +) -> String { + get_client_ip_internal(headers, trusted_proxy_count(), peer) +} + #[cfg(feature = "server")] /// 使用环境变量配置的代理层数提取客户端 IP。 +/// +/// 适用于 Dioxus server function 等无法获取 `ConnectInfo` 的场景。 +/// 生产环境建议配合反向代理与 `TRUSTED_PROXY_COUNT` 使用。 pub fn get_client_ip(headers: &http::HeaderMap) -> String { - get_client_ip_with_trusted(headers, trusted_proxy_count()) + get_client_ip_internal(headers, trusted_proxy_count(), None) } #[cfg(feature = "server")] @@ -148,12 +204,16 @@ pub fn check_upload_limit(ip: &str) -> Result<(), String> { mod tests { use super::*; use http::HeaderMap; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[test] fn get_client_ip_from_x_forwarded_for_with_one_trusted_proxy() { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 1), "1.2.3.4"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "1.2.3.4" + ); } #[test] @@ -163,28 +223,52 @@ mod tests { "x-forwarded-for", "1.2.3.4, 5.6.7.8, 9.10.11.12".parse().unwrap(), ); - assert_eq!(get_client_ip_with_trusted(&headers, 2), "1.2.3.4"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 2, None), + "1.2.3.4" + ); } #[test] fn get_client_ip_ignores_x_forwarded_for_when_no_trusted_proxies() { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 0), "unknown"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 0, None), + "unknown" + ); + } + + #[test] + fn get_client_ip_falls_back_to_peer_when_no_trusted_proxies() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap()); + let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 0, Some(peer)), + "127.0.0.1" + ); } #[test] fn get_client_ip_from_x_real_ip_when_trusted() { let mut headers = HeaderMap::new(); headers.insert("x-real-ip", "9.8.7.6".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 1), "9.8.7.6"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "9.8.7.6" + ); } #[test] fn get_client_ip_x_real_ip_ignored_when_not_trusted() { let mut headers = HeaderMap::new(); headers.insert("x-real-ip", "9.8.7.6".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 0), "unknown"); + let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 12345); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 0, Some(peer)), + "192.168.1.1" + ); } #[test] @@ -192,27 +276,39 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", "1.1.1.1, 2.2.2.2".parse().unwrap()); headers.insert("x-real-ip", "3.3.3.3".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 1), "1.1.1.1"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "1.1.1.1" + ); } #[test] fn get_client_ip_no_headers_returns_unknown() { let headers = HeaderMap::new(); - assert_eq!(get_client_ip_with_trusted(&headers, 1), "unknown"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "unknown" + ); } #[test] fn get_client_ip_ignores_short_x_forwarded_for_list() { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 2), "unknown"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 2, None), + "unknown" + ); } #[test] fn get_client_ip_ignores_x_forwarded_for_equal_to_proxy_count() { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap()); - assert_eq!(get_client_ip_with_trusted(&headers, 2), "unknown"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 2, None), + "unknown" + ); } #[test] @@ -222,7 +318,41 @@ mod tests { "x-forwarded-for", " , 1.2.3.4 , 5.6.7.8 , ".parse().unwrap(), ); - assert_eq!(get_client_ip_with_trusted(&headers, 1), "1.2.3.4"); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "1.2.3.4" + ); + } + + #[test] + fn get_client_ip_rejects_invalid_x_forwarded_for_value() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "not-an-ip, 5.6.7.8".parse().unwrap()); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "unknown" + ); + } + + #[test] + fn get_client_ip_rejects_invalid_x_real_ip_value() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", "not-an-ip".parse().unwrap()); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, None), + "unknown" + ); + } + + #[test] + fn get_client_ip_prefers_xff_over_peer() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap()); + let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345); + assert_eq!( + get_client_ip_with_trusted_and_peer(&headers, 1, Some(peer)), + "1.2.3.4" + ); } #[test] @@ -239,4 +369,13 @@ mod tests { None => std::env::remove_var("TRUSTED_PROXY_COUNT"), } } + + // 测试辅助函数:绕过环境变量读取,直接指定 trusted_proxy_count。 + fn get_client_ip_with_trusted_and_peer( + headers: &HeaderMap, + trusted: usize, + peer: Option, + ) -> String { + get_client_ip_internal(headers, trusted, peer) + } } diff --git a/src/api/sanitizer.rs b/src/api/sanitizer.rs index 4b2b24a..73383f5 100644 --- a/src/api/sanitizer.rs +++ b/src/api/sanitizer.rs @@ -137,33 +137,56 @@ fn default_allowed_schemes() -> HashSet<&'static str> { set } +#[cfg(feature = "server")] +fn is_safe_data_uri(url: &str) -> bool { + // data URI 只允许安全的图片类型;禁止 data:text/html、data:application/javascript 等。 + let url = url.trim(); + let Some(rest) = url.strip_prefix("data:") else { + return false; + }; + let media_type = rest.split(',').next().unwrap_or(""); + let media_type = media_type.split(';').next().unwrap_or("").trim(); + matches!( + media_type.to_lowercase().as_str(), + "image/png" + | "image/jpeg" + | "image/jpg" + | "image/gif" + | "image/webp" + | "image/avif" + | "image/bmp" + | "image/tiff" + | "image/svg+xml" + ) +} + #[cfg(feature = "server")] fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) -> bool { let trimmed = url.trim(); if trimmed.is_empty() { return true; } - // 解析 scheme 并与白名单对比;data URI 与 javascript/vbscript 单独处理。 + // 解析 scheme 并与白名单对比;未知 scheme 默认拒绝。 if let Some(colon_pos) = trimmed.find(':') { let scheme = &trimmed[..colon_pos]; let scheme_lower = scheme.to_lowercase(); - if allowed_schemes.contains(scheme_lower.as_str()) { - return true; - } - if scheme_lower == "data" { - return allow_data_uri; - } if scheme_lower == "javascript" || scheme_lower == "vbscript" { return false; } if scheme.contains(|c: char| c.is_ascii_whitespace()) { return false; } + if allowed_schemes.contains(scheme_lower.as_str()) { + return true; + } + if scheme_lower == "data" { + return allow_data_uri && is_safe_data_uri(trimmed); + } + // 任何其它 scheme 均拒绝:file://、blob://、about:blank 等。 + return false; } - if trimmed.starts_with('#') || trimmed.starts_with('/') { - return true; - } - true + // 无 scheme 时只允许相对路径与锚点。 + trimmed.starts_with('#') || trimmed.starts_with('/') } #[cfg(feature = "server")] @@ -329,7 +352,7 @@ pub fn clean_html(input: &str) -> String { ("h6", vec!["id", "class"]), ], allowed_schemes: default_allowed_schemes(), - allow_data_uri: true, + allow_data_uri: false, link_rel: Some("noopener noreferrer"), remove_tags: clean_content_tags(), }; @@ -459,12 +482,16 @@ mod tests { } #[test] - fn is_safe_url_data_uri_respects_flag() { + fn is_safe_url_data_uri_respects_flag_and_media_type() { let schemes = default_allowed_schemes(); - // 文章正文允许 data URI + // 仅在显式允许且 media type 为图片时通过 assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true)); - // 评论禁用 data URI + assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true)); + // 禁用 data URI 时拒绝 assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false)); + // 非图片 data URI 拒绝 + assert!(!is_safe_url("data:text/html,", &schemes, true)); + assert!(!is_safe_url("data:application/javascript,alert(1)", &schemes, true)); } #[test] @@ -500,6 +527,16 @@ mod tests { assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false)); } + #[test] + fn is_safe_url_rejects_unknown_schemes() { + let schemes = default_allowed_schemes(); + // 未知 scheme 默认拒绝。 + assert!(!is_safe_url("file:///etc/passwd", &schemes, false)); + assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false)); + assert!(!is_safe_url("about:blank", &schemes, false)); + assert!(!is_safe_url("custom-app://open", &schemes, false)); + } + #[test] fn is_safe_url_scheme_matching_is_case_insensitive() { let schemes = default_allowed_schemes(); diff --git a/src/api/slug.rs b/src/api/slug.rs index aac06cd..ef3eede 100644 --- a/src/api/slug.rs +++ b/src/api/slug.rs @@ -53,8 +53,11 @@ pub fn is_valid_slug(slug: &str) -> bool { /// /// 若 `exclude_id` 不为空,则排除该文章自身; /// 当冲突时依次尝试 `base-2`、`base-3` …… 直到生成唯一值。 +/// +/// 该函数应在事务内调用,确保与后续 INSERT/UPDATE 的 slug 唯一性检查 +/// 在同一个事务中完成,避免并发竞态。 pub async fn ensure_unique_slug( - client: &tokio_postgres::Client, + tx: &deadpool_postgres::Transaction<'_>, base: &str, exclude_id: Option, ) -> Result { @@ -66,23 +69,21 @@ pub async fn ensure_unique_slug( loop { // 查询当前候选 slug 是否已存在(排除指定文章 ID)。 let exists = if let Some(exclude) = exclude_id { - client - .query_opt( - "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2", - &[&candidate, &exclude], - ) - .await - .map_err(AppError::query)? - .is_some() + tx.query_opt( + "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2", + &[&candidate, &exclude], + ) + .await + .map_err(AppError::query)? + .is_some() } else { - client - .query_opt( - "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL", - &[&candidate], - ) - .await - .map_err(AppError::query)? - .is_some() + tx.query_opt( + "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL", + &[&candidate], + ) + .await + .map_err(AppError::query)? + .is_some() }; if !exists { diff --git a/src/api/upload.rs b/src/api/upload.rs index 2df61e2..2e91da1 100644 --- a/src/api/upload.rs +++ b/src/api/upload.rs @@ -7,11 +7,13 @@ #[cfg(feature = "server")] use axum::{ - extract::Multipart, + extract::{ConnectInfo, Multipart}, http::{HeaderMap, StatusCode}, response::Json, }; #[cfg(feature = "server")] +use std::net::SocketAddr; +#[cfg(feature = "server")] use serde_json::{json, Value}; #[cfg(feature = "server")] @@ -33,17 +35,48 @@ fn mime_to_ext(mime: &str) -> &'static str { } } +#[cfg(feature = "server")] +/// 通过文件头 magic bytes 校验实际格式是否与声明 MIME 一致。 +fn validate_image_magic_bytes(data: &[u8], mime_type: &str) -> bool { + if data.is_empty() { + return false; + } + match mime_type { + "image/jpeg" => data.starts_with(&[0xFF, 0xD8, 0xFF]), + "image/png" => data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + "image/gif" => data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a"), + "image/webp" => { + // RIFF....WEBP + data.len() >= 12 + && &data[0..4] == b"RIFF" + && &data[8..12] == b"WEBP" + } + _ => false, + } +} + +#[cfg(feature = "server")] +/// 解码验证 GIF/WebP 原始字节,确保不是伪造扩展名的恶意文件。 +fn validate_raw_image(data: &[u8], mime_type: &str) -> bool { + match mime_type { + "image/webp" => crate::webp::decode(data).is_ok(), + "image/gif" => image::load_from_memory(data).is_ok(), + _ => true, + } +} + #[cfg(feature = "server")] /// 处理图片上传的 Axum handler。 /// /// 流程:限流 → 解析 session → 校验 admin → 读取 multipart → 校验类型/大小 → /// 转码(如适用)→ 按日期落盘 → 返回相对 URL。 pub async fn upload_image( + ConnectInfo(addr): ConnectInfo, headers: HeaderMap, mut multipart: Multipart, ) -> Result, (StatusCode, Json)> { // 0. Rate limit check - let ip = crate::api::rate_limit::get_client_ip(&headers); + let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, Some(addr)); if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) { return Err(( StatusCode::TOO_MANY_REQUESTS, @@ -158,9 +191,31 @@ pub async fn upload_image( )); } + // 校验文件头 magic bytes,防止仅修改扩展名/Content-Type 上传非图片文件。 + if !validate_image_magic_bytes(&data, mime_type.as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": "文件类型与内容不符" + })), + )); + } + let is_gif = mime_type.as_str() == "image/gif"; let is_webp = mime_type.as_str() == "image/webp"; + // 对不经过重编码的格式做解码验证。 + if (is_gif || is_webp) && !validate_raw_image(&data, mime_type.as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": "图片文件损坏或格式不正确" + })), + )); + } + // GIF 与 WebP 保持原格式;其余格式尝试转 WebP。 let (final_data, final_ext) = if is_gif { (data.to_vec(), "gif".to_string()) @@ -173,7 +228,20 @@ pub async fn upload_image( // 在阻塞线程中执行图片解码与 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) { + let cursor = std::io::Cursor::new(&original_data); + let format = match mime.as_str() { + "image/jpeg" => image::ImageFormat::Jpeg, + "image/png" => image::ImageFormat::Png, + _ => image::ImageFormat::Jpeg, + }; + let mut reader = image::ImageReader::with_format(cursor, format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(crate::api::image::MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(crate::api::image::MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(crate::api::image::MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024); + reader.limits(limits); + + match reader.decode() { Ok(img) => { let decode_time = total_start.elapsed(); let enc_start = std::time::Instant::now(); @@ -339,4 +407,33 @@ mod tests { assert_eq!(super::mime_to_ext("image/avif"), "bin"); assert_eq!(super::mime_to_ext("application/octet-stream"), "bin"); } + + #[test] + fn validate_jpeg_magic_bytes() { + assert!(super::validate_image_magic_bytes(&[0xFF, 0xD8, 0xFF], "image/jpeg")); + assert!(!super::validate_image_magic_bytes(&[0x89, 0x50], "image/jpeg")); + } + + #[test] + fn validate_png_magic_bytes() { + assert!(super::validate_image_magic_bytes( + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + "image/png" + )); + assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/png")); + } + + #[test] + fn validate_gif_magic_bytes() { + assert!(super::validate_image_magic_bytes(b"GIF89a", "image/gif")); + assert!(super::validate_image_magic_bytes(b"GIF87a", "image/gif")); + assert!(!super::validate_image_magic_bytes(b"GIF90a", "image/gif")); + } + + #[test] + fn validate_webp_magic_bytes() { + let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 "; + assert!(super::validate_image_magic_bytes(&webp[..12], "image/webp")); + assert!(!super::validate_image_magic_bytes(&[0xFF, 0xD8], "image/webp")); + } } diff --git a/src/bin/generate_highlight_css.rs b/src/bin/generate_highlight_css.rs index cfb65be..7c94aee 100644 --- a/src/bin/generate_highlight_css.rs +++ b/src/bin/generate_highlight_css.rs @@ -94,6 +94,7 @@ fn strip_comments(css: &str) -> String { /// - `css`: syntect 生成的原始 CSS; /// - `base`: 作用域基础选择器,这里固定为 `.md-content pre code`; /// - `prefix`: 主题前缀,浅色主题传空字符串,深色主题传 `.dark `。 +/// /// 压缩 CSS:移除注释、合并空白、删除选择器/属性周围的无用空格。 fn minify_css(css: &str) -> String { let mut out = String::with_capacity(css.len()); @@ -124,7 +125,7 @@ fn minify_css(css: &str) -> String { // 在特定分隔符前后不需要空格 let no_space_before = "{ } : ; ,".contains(ch); - let no_space_after = last_significant.map_or(false, |c| "{ } : ; ,".contains(c)); + let no_space_after = last_significant.is_some_and(|c| "{ } : ; ,".contains(c)); if pending_space && !no_space_after && !no_space_before { out.push(' '); diff --git a/src/components/forms.rs b/src/components/forms.rs index 0dbc6ba..5960806 100644 --- a/src/components/forms.rs +++ b/src/components/forms.rs @@ -13,6 +13,7 @@ pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text- /// 表单输入框组件。 /// /// Props: +/// - `id`:input 元素 id,用于与 label 关联 /// - `r#type`:input 类型(如 `"text"`、`"email"`、`"password"`) /// - `placeholder`:占位提示文本 /// - `value`:当前值 @@ -21,6 +22,7 @@ pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text- /// - `onkeydown`:可选的键盘事件回调 #[component] pub fn FormInput( + id: Option, r#type: &'static str, placeholder: &'static str, value: String, @@ -35,6 +37,7 @@ pub fn FormInput( }; rsx! { input { + id: id.unwrap_or_default(), class: "{INPUT_CLASS} {disabled_class}", r#type: "{r#type}", placeholder: "{placeholder}", @@ -54,10 +57,13 @@ pub fn FormInput( /// /// Props: /// - `label`:标签文本 +/// - `html_for`:关联的 input id #[component] -pub fn FormLabel(label: &'static str) -> Element { +pub fn FormLabel(label: &'static str, html_for: Option) -> Element { rsx! { - label { class: "block text-sm font-medium text-paper-secondary mb-1", + label { + class: "block text-sm font-medium text-paper-secondary mb-1", + r#for: html_for.unwrap_or_default(), "{label}" } } diff --git a/src/components/header.rs b/src/components/header.rs index cff58c7..65f10b4 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -1,7 +1,7 @@ //! 顶部导航栏组件 //! //! 提供站点 Logo、响应式导航菜单项与右侧自定义内容区, -//! 支持前台布局与后台布局复用。 +//! 支持前台布局与后台布局复用,并包含小屏幕下的汉堡菜单。 use dioxus::prelude::*; use dioxus::router::components::Link; @@ -31,6 +31,9 @@ pub struct NavItemConfig { /// - `right_content`:右侧自定义内容(如主题切换、登出按钮) #[component] pub fn Header(nav_items: Vec, right_content: Element) -> Element { + let mut mobile_open = use_signal(|| false); + let menu_id = use_memo(|| "mobile-nav-menu".to_string()); + rsx! { header { class: "sticky top-0 z-40 w-full border-b border-paper-border bg-paper-theme/80 backdrop-blur-sm", nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between", @@ -40,6 +43,7 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element "Yggdrasil" } div { class: "flex items-center gap-2", + // 桌面端导航 ul { class: "hidden md:flex items-center gap-1", for item in nav_items.iter().cloned() { NavItem { @@ -49,19 +53,74 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element } } } + {right_content} + + // 移动端汉堡菜单按钮 + button { + class: "md:hidden p-2 rounded-lg text-paper-secondary hover:text-paper-primary hover:bg-paper-entry transition-colors", + r#type: "button", + aria_label: "切换导航菜单", + aria_expanded: "{mobile_open()}", + aria_controls: "{menu_id()}", + onclick: move |_| mobile_open.set(!mobile_open()), + if mobile_open() { + // 关闭图标(X) + svg { + class: "w-6 h-6", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M6 18L18 6M6 6l12 12" + } + } + } else { + // 汉堡图标 + svg { + class: "w-6 h-6", + fill: "none", + stroke: "currentColor", + view_box: "0 0 24 24", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + stroke_width: "2", + d: "M4 6h16M4 12h16M4 18h16" + } + } + } + } + } + } + + // 移动端导航面板 + if mobile_open() { + div { + id: "{menu_id()}", + class: "md:hidden border-t border-paper-border bg-paper-theme/95 backdrop-blur-sm", + ul { class: "py-2 px-6 space-y-1", + for item in nav_items.iter().cloned() { + li { + MobileNavItem { + route: item.route, + label: item.label, + is_active: item.is_active, + on_navigate: move |_| mobile_open.set(false), + } + } + } + } } } } } } -/// 单个导航项组件,根据 `is_active` 切换高亮样式。 -/// -/// Props: -/// - `route`:目标路由 -/// - `label`:显示文本 -/// - `is_active`:是否高亮 +/// 单个桌面导航项组件,根据 `is_active` 切换高亮样式。 #[component] fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element { let base_class = "px-3 py-1 text-base rounded-lg transition-all duration-200"; @@ -84,3 +143,27 @@ fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element { } } } + +/// 单个移动端导航项组件,点击后关闭菜单。 +#[component] +fn MobileNavItem( + route: Route, + label: &'static str, + is_active: bool, + on_navigate: EventHandler<()>, +) -> Element { + let class_str = if is_active { + "block w-full px-3 py-2 text-base font-medium text-paper-accent rounded-lg bg-paper-entry" + } else { + "block w-full px-3 py-2 text-base text-paper-secondary hover:text-paper-primary hover:bg-paper-entry rounded-lg transition-colors" + }; + + rsx! { + Link { + class: "{class_str}", + to: route, + onclick: move |_| on_navigate.call(()), + "{label}" + } + } +} diff --git a/src/components/image_viewer.rs b/src/components/image_viewer.rs index ad03c1a..704f0e9 100644 --- a/src/components/image_viewer.rs +++ b/src/components/image_viewer.rs @@ -2,6 +2,7 @@ //! //! 提供缩略图展示与点击放大后的全屏灯箱(lightbox)查看, //! 支持自定义缩略图参数、alt 文本与懒加载。 +//! 灯箱支持键盘操作:Escape 关闭、Tab 在关闭按钮与图片间循环。 use dioxus::prelude::*; @@ -17,6 +18,7 @@ use dioxus::prelude::*; /// - 点击缩略图:打开全屏灯箱 /// - 点击遮罩或关闭按钮:关闭灯箱 /// - 点击灯箱内容区:阻止事件冒泡,避免误关闭 +/// - 键盘 Escape:关闭灯箱 #[component] pub fn ImageViewer( src: String, @@ -26,6 +28,40 @@ pub fn ImageViewer( ) -> Element { let mut is_open = use_signal(|| false); + // 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。 + #[cfg(target_arch = "wasm32")] + { + use_effect(move || { + if !is_open() { + return; + } + + wasm_bindgen_futures::spawn_local(async move { + // 下一帧再聚焦,确保 DOM 已渲染。 + let _ = js_sys::eval("new Promise(r => requestAnimationFrame(r))").unwrap(); + if let Some(btn) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.query_selector(".image-viewer-close").ok()) + .flatten() + { + let _ = btn.focus(); + } + }); + + let closure = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| { + if e.key() == "Escape" { + is_open.set(false); + } + }) as Box); + + if let Some(window) = web_sys::window() { + let _ = window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()); + } + + closure.forget(); + }); + } + // 拼接缩略图 URL:保留原 URL 的 query 参数并追加 thumb_params let thumb_src = if src.contains('?') { format!( @@ -51,6 +87,9 @@ pub fn ImageViewer( if is_open() { div { class: "image-viewer-overlay", + role: "dialog", + aria_modal: "true", + aria_label: "图片预览", onclick: move |_| is_open.set(false), div { class: "image-viewer-content", @@ -59,9 +98,12 @@ pub fn ImageViewer( class: "image-viewer-img", src: "{src}", alt: "{alt}", + tabindex: 0, } button { class: "image-viewer-close", + r#type: "button", + aria_label: "关闭图片预览", onclick: move |_| is_open.set(false), "✕" } diff --git a/src/db/pool.rs b/src/db/pool.rs index 11bab36..8a3eec9 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -19,9 +19,9 @@ pub static DB_POOL: LazyLock = LazyLock::new(|| { .parse::() .expect("Invalid DATABASE_URL format"); - // 使用 Fast 回收策略,避免每次归还连接时执行额外查询。 + // 使用 Verified 回收策略,确保归还的连接仍然可用,避免 DB 重启后拿到死连接。 let mgr_cfg = ManagerConfig { - recycling_method: RecyclingMethod::Fast, + recycling_method: RecyclingMethod::Verified, }; let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg); @@ -32,6 +32,9 @@ pub static DB_POOL: LazyLock = LazyLock::new(|| { .and_then(|s| s.parse().ok()) .unwrap_or(20), ) + .wait_timeout(Some(Duration::from_secs(10))) + .create_timeout(Some(Duration::from_secs(10))) + .recycle_timeout(Some(Duration::from_secs(5))) .build() .expect("Failed to create database connection pool") }); diff --git a/src/main.rs b/src/main.rs index ee966d0..31ec245 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,11 +83,12 @@ fn main() { ), ); - // 自定义 API 路由:图片上传,禁用默认请求体大小限制以支持大文件 + // 自定义 API 路由:图片上传,设置最大请求体大小为 10 MiB + // (包含 multipart 开销,实际文件限制由 upload_image 内 MAX_FILE_SIZE 控制) let api_routes = axum::Router::new().route( "/api/upload", axum::routing::post(crate::api::upload::upload_image) - .layer(axum::extract::DefaultBodyLimit::disable()), + .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)), ); // 静态资源路由:图片文件服务,支持动态裁剪/旋转/格式转换 diff --git a/src/middleware/minify_html.rs b/src/middleware/minify_html.rs index de2f639..4bc3eff 100644 --- a/src/middleware/minify_html.rs +++ b/src/middleware/minify_html.rs @@ -28,6 +28,7 @@ static MINIFY_CACHE: std::sync::LazyLock> = /// Axum 中间件入口。 pub async fn layer(request: Request, next: Next) -> Response { let uri = request.uri().clone(); + let path = uri.path().to_string(); let response = next.run(request).await; let is_html = response @@ -41,10 +42,22 @@ pub async fn layer(request: Request, next: Next) -> Response { return response; } - // 命中缓存则直接返回 - let cache_key = uri.to_string(); - if let Some(cached) = MINIFY_CACHE.get(&cache_key).await { - return build_response(response, cached); + // 不缓存错误响应与后台管理页面,避免把错误页或敏感管理界面扩散。 + let status = response.status(); + let is_admin_or_auth = path.starts_with("/admin") || path == "/login" || path == "/register"; + let should_cache = status.is_success() && !is_admin_or_auth; + + // 缓存 key 必须包含完整 query string,避免不同参数共享同一份响应。 + let cache_key = format!( + "{}{}", + path, + uri.query().map(|q| format!("?{}", q)).unwrap_or_default() + ); + + if should_cache { + if let Some(cached) = MINIFY_CACHE.get(&cache_key).await { + return build_response(response, cached); + } } let (parts, body) = response.into_parts(); @@ -61,8 +74,10 @@ pub async fn layer(request: Request, next: Next) -> Response { let original = String::from_utf8_lossy(&bytes); let minified = crate::utils::html_minify::minify_html(&original); - // 写入缓存(忽略失败) - let _ = MINIFY_CACHE.insert(cache_key, minified.clone()).await; + // 仅对成功且非后台页面写入缓存。 + if should_cache { + let _ = MINIFY_CACHE.insert(cache_key, minified.clone()).await; + } let mut response = Response::builder() .status(parts.status) diff --git a/src/pages/login.rs b/src/pages/login.rs index 3370210..3575343 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -37,11 +37,7 @@ pub fn Login() -> Element { // 在异步任务中调用 server function 登录 spawn(async move { match login(username_val, password_val).await { - Ok(AuthResponse { - success: true, - token: Some(_token), - .. - }) => { + Ok(AuthResponse { success: true, .. }) => { // 登录成功:重置上下文检查标记并跳转到后台 ctx.checked.set(false); let _ = dioxus::router::navigator().push(Route::Admin {}); @@ -53,13 +49,6 @@ pub fn Login() -> Element { }) => { error.set(Some(message)); } - Ok(AuthResponse { - success: true, - token: None, - .. - }) => { - error.set(Some("登录异常".to_string())); - } Err(e) => { error.set(Some(format!("请求失败: {}", e))); } @@ -83,8 +72,9 @@ pub fn Login() -> Element { div { class: "space-y-4", div { - FormLabel { label: "用户名 / 邮箱" } + FormLabel { label: "用户名 / 邮箱", html_for: Some("login-username".to_string()) } FormInput { + id: Some("login-username".to_string()), r#type: "text", placeholder: "用户名或邮箱", value: username(), @@ -95,8 +85,9 @@ pub fn Login() -> Element { } } div { - FormLabel { label: "密码" } + FormLabel { label: "密码", html_for: Some("login-password".to_string()) } FormInput { + id: Some("login-password".to_string()), r#type: "password", placeholder: "密码", value: password(), diff --git a/src/pages/post_detail.rs b/src/pages/post_detail.rs index 41927a1..d408f94 100644 --- a/src/pages/post_detail.rs +++ b/src/pages/post_detail.rs @@ -50,7 +50,7 @@ pub fn PostDetail(slug: String) -> Element { match post_data { Some(Ok(post)) => { rsx! { - article { class: "post-single", + article { class: "post-single", key: "{slug}", PostHeader { post: post.clone() } // 如果文章设置了封面图,则渲染封面组件。 diff --git a/src/pages/register.rs b/src/pages/register.rs index b720e63..660819a 100644 --- a/src/pages/register.rs +++ b/src/pages/register.rs @@ -96,8 +96,9 @@ pub fn Register() -> Element { div { class: "space-y-4", div { - FormLabel { label: "用户名" } + FormLabel { label: "用户名", html_for: Some("register-username".to_string()) } FormInput { + id: Some("register-username".to_string()), r#type: "text", placeholder: "3-50 位字符", value: username(), @@ -107,8 +108,9 @@ pub fn Register() -> Element { } } div { - FormLabel { label: "邮箱" } + FormLabel { label: "邮箱", html_for: Some("register-email".to_string()) } FormInput { + id: Some("register-email".to_string()), r#type: "email", placeholder: "your@email.com", value: email(), @@ -118,8 +120,9 @@ pub fn Register() -> Element { } } div { - FormLabel { label: "密码" } + FormLabel { label: "密码", html_for: Some("register-password".to_string()) } FormInput { + id: Some("register-password".to_string()), r#type: "password", placeholder: "至少 8 位", value: password(), @@ -129,8 +132,9 @@ pub fn Register() -> Element { } } div { - FormLabel { label: "确认密码" } + FormLabel { label: "确认密码", html_for: Some("register-confirm-password".to_string()) } FormInput { + id: Some("register-confirm-password".to_string()), r#type: "password", placeholder: "再次输入密码", value: confirm_password(), diff --git a/src/tasks/ip_purge.rs b/src/tasks/ip_purge.rs index 4537f96..d53f56c 100644 --- a/src/tasks/ip_purge.rs +++ b/src/tasks/ip_purge.rs @@ -13,7 +13,6 @@ 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 的评论 @@ -28,5 +27,6 @@ pub async fn run_purge() { tracing::error!("Failed to get DB connection for IP purge: {:?}", e); } } + ticker.tick().await; } } diff --git a/src/tasks/post_purge.rs b/src/tasks/post_purge.rs index 58107a5..4cc8ea7 100644 --- a/src/tasks/post_purge.rs +++ b/src/tasks/post_purge.rs @@ -18,7 +18,6 @@ use crate::models::settings::TrashSettings; pub async fn run_purge() { let mut ticker = interval(Duration::from_secs(86400)); loop { - ticker.tick().await; match get_conn().await { Ok(client) => { match purge_expired(&client).await { @@ -32,6 +31,7 @@ pub async fn run_purge() { } Err(e) => tracing::error!("Failed to get DB connection for post purge: {:?}", e), } + ticker.tick().await; } } diff --git a/src/tasks/session_cleanup.rs b/src/tasks/session_cleanup.rs index 7eac39b..794e6b6 100644 --- a/src/tasks/session_cleanup.rs +++ b/src/tasks/session_cleanup.rs @@ -13,7 +13,6 @@ pub async fn run_cleanup() { // 每小时触发一次 let mut ticker = interval(Duration::from_secs(3600)); loop { - ticker.tick().await; match get_conn().await { Ok(client) => { // 删除已过期会话 @@ -28,5 +27,6 @@ pub async fn run_cleanup() { tracing::error!("Failed to get DB connection for cleanup: {:?}", e); } } + ticker.tick().await; } } diff --git a/src/theme.rs b/src/theme.rs index 8bc55e5..20e7ee3 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -160,17 +160,14 @@ pub fn ThemePreload() -> Element { #[component] pub fn ThemeToggle() -> Element { let mut theme = use_theme(); - let mut mounted = use_signal(|| false); - - use_effect(move || { - mounted.set(true); - }); rsx! { button { class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary", + r#type: "button", + aria_label: "切换深色/浅色主题", onclick: move |_| theme.set(theme().toggle()), - if mounted() && theme() == Theme::Dark { + if theme() == Theme::Dark { svg { xmlns: "http://www.w3.org/2000/svg", height: "24px",