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.
This commit is contained in:
parent
bd659c5b4f
commit
449a545886
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5328,7 +5328,6 @@ dependencies = [
|
||||
"syntect",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
|
||||
@ -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",
|
||||
|
||||
4
Makefile
4
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
@ -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)
|
||||
|
||||
10
migrations/003_indexes.sql
Normal file
10
migrations/003_indexes.sql
Normal file
@ -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);
|
||||
@ -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,
|
||||
9
migrations/008_comments_cascade.sql
Normal file
9
migrations/008_comments_cascade.sql
Normal file
@ -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;
|
||||
@ -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;
|
||||
|
||||
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()
|
||||
let message = if admin_exists {
|
||||
"Registration is closed".to_string()
|
||||
} else {
|
||||
format!("注册失败: {}", e)
|
||||
"用户名或邮箱已存在".to_string()
|
||||
};
|
||||
|
||||
Ok(AuthResponse {
|
||||
success: false,
|
||||
message: msg,
|
||||
message,
|
||||
token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登录。
|
||||
@ -271,7 +269,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
||||
Ok(AuthResponse {
|
||||
success: true,
|
||||
message: "登录成功".to_string(),
|
||||
token: Some(token),
|
||||
token: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -219,6 +219,13 @@ pub async fn create_comment(
|
||||
// 将 Markdown 渲染为 HTML,并通过 sanitizer 过滤危险标签。
|
||||
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
|
||||
|
||||
// 对作者展示字段做 HTML 转义,避免 XSS;URL 为空字符串时统一为 None。
|
||||
let author_name_safe = crate::api::comments::helpers::escape_html(author_name.trim());
|
||||
let author_url_safe = author_url
|
||||
.as_ref()
|
||||
.map(|u| crate::api::comments::helpers::escape_html(u.trim()))
|
||||
.filter(|u| !u.is_empty());
|
||||
|
||||
// 获取客户端 IP 与 User-Agent,用于反垃圾与审计。
|
||||
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||
let parts = ctx.parts_mut();
|
||||
@ -250,12 +257,9 @@ pub async fn create_comment(
|
||||
&post_id,
|
||||
&parent_id,
|
||||
&depth,
|
||||
&author_name.trim(),
|
||||
&author_name_safe,
|
||||
&author_email.trim(),
|
||||
&author_url
|
||||
.as_ref()
|
||||
.map(|u| u.trim())
|
||||
.filter(|u| !u.is_empty()),
|
||||
&author_url_safe,
|
||||
&content_md,
|
||||
&content_html,
|
||||
&content_hash,
|
||||
@ -271,9 +275,10 @@ pub async fn create_comment(
|
||||
// 根据邮箱生成 Gravatar 头像链接。
|
||||
let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
|
||||
|
||||
// 新评论可能影响文章评论列表与计数,清空相关缓存。
|
||||
// 新评论可能影响文章评论列表、评论计数与待审核计数,清空相关缓存。
|
||||
cache::invalidate_comments_by_post(post_id).await;
|
||||
cache::invalidate_comment_count(post_id).await;
|
||||
cache::invalidate_pending_count().await;
|
||||
|
||||
Ok(CommentResponse {
|
||||
success: true,
|
||||
|
||||
@ -15,6 +15,17 @@ pub fn md5_hash(input: &str) -> String {
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
/// 对用于 HTML 展示的文本做基础转义,防止 XSS。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 根据邮箱生成 Cravatar(Gravatar 国内镜像)头像 URL。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn gravatar_url(email: &str) -> String {
|
||||
@ -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>"),
|
||||
"<script>alert(1)</script>"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_html("\"quoted' & ampersand"),
|
||||
""quoted' & ampersand"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md5_hash_known_value() {
|
||||
assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
|
||||
|
||||
@ -33,7 +33,9 @@ pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnE
|
||||
"SELECT id, parent_id, depth, author_name, author_email, author_url, content_html, created_at \
|
||||
FROM comments \
|
||||
WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL \
|
||||
ORDER BY id ASC",
|
||||
AND EXISTS (SELECT 1 FROM posts p WHERE p.id = $1 AND p.status = 'published' AND p.deleted_at IS NULL) \
|
||||
ORDER BY id ASC \
|
||||
LIMIT 200",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
|
||||
@ -27,21 +27,23 @@ pub enum AppError {
|
||||
#[cfg(feature = "server")]
|
||||
impl AppError {
|
||||
/// 记录并包装数据库连接错误。
|
||||
pub fn db_conn(e: impl std::fmt::Debug) -> Self {
|
||||
tracing::error!("DB connection failed: {e:?}");
|
||||
AppError::DbConn(format!("{e:?}"))
|
||||
///
|
||||
/// 日志仅记录 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<u8>, 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<SocketAddr>,
|
||||
Path(path): Path<String>,
|
||||
Query(params): Query<ImageParams>,
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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("");
|
||||
// 出于 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\""));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -201,14 +201,14 @@ pub(super) async fn sync_tags(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清洗标签列表:去头尾空白并过滤空字符串。
|
||||
///
|
||||
/// 注意:该函数保留重复标签,由数据库唯一索引或调用方决定去重。
|
||||
/// 清洗标签列表:去头尾空白、过滤空字符串并去重(保留原始顺序)。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
|
||||
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()]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -261,7 +261,8 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
|
||||
LEFT JOIN tags t2 ON pt2.tag_id = t2.id
|
||||
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL
|
||||
GROUP BY p.id
|
||||
ORDER BY p.published_at DESC",
|
||||
ORDER BY p.published_at DESC
|
||||
LIMIT 200",
|
||||
&[&tag_name],
|
||||
)
|
||||
.await
|
||||
|
||||
@ -97,7 +97,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
|
||||
ORDER BY published_at ASC
|
||||
LIMIT 1
|
||||
) next ON true
|
||||
WHERE p.slug = $1 AND p.deleted_at IS NULL
|
||||
WHERE p.slug = $1 AND p.status = 'published' AND p.deleted_at IS NULL
|
||||
GROUP BY p.id, prev.title, prev.slug, next.title, next.slug",
|
||||
&[&slug],
|
||||
)
|
||||
|
||||
@ -23,15 +23,35 @@ use crate::db::pool::get_conn;
|
||||
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
use crate::api::rate_limit;
|
||||
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
// 对搜索接口进行严格限流,防止滥用 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() || 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
|
||||
@ -40,16 +60,16 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
|
||||
word_similarity(p.search_text, $1) AS sml
|
||||
word_similarity(p.search_text, $2) AS sml
|
||||
FROM posts p
|
||||
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE p.status = 'published' AND p.deleted_at IS NULL
|
||||
AND p.search_text ILIKE '%' || $1 || '%'
|
||||
AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
|
||||
GROUP BY p.id, p.search_text
|
||||
ORDER BY sml DESC, p.published_at DESC
|
||||
LIMIT 50",
|
||||
&[&q],
|
||||
&[&escaped, &q],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
@ -25,10 +25,11 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||
|
||||
// 读取待恢复文章的当前 slug 与是否确已删除。
|
||||
let row = client
|
||||
let row = tx
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
&[&post_id],
|
||||
@ -47,17 +48,17 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
||||
|
||||
let current_slug: String = row.get("slug");
|
||||
|
||||
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀)。
|
||||
let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(post_id)).await?;
|
||||
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。
|
||||
let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(post_id)).await?;
|
||||
|
||||
// 置空 deleted_at,并更新 slug(可能已加后缀)。
|
||||
let result = client
|
||||
let result = tx
|
||||
.execute(
|
||||
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||
&[&new_slug, &post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
.map_err(AppError::tx)?;
|
||||
|
||||
if result == 0 {
|
||||
return Ok(CreatePostResponse {
|
||||
@ -68,6 +69,8 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(AppError::tx)?;
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
@ -155,12 +158,13 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||
|
||||
// 逐条恢复,slug 冲突时自动加后缀。
|
||||
let mut restored = 0u64;
|
||||
for id in &post_ids {
|
||||
let row = client
|
||||
let row = tx
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
&[&id],
|
||||
@ -169,18 +173,20 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
|
||||
.map_err(AppError::query)?;
|
||||
if let Some(row) = row {
|
||||
let current_slug: String = row.get("slug");
|
||||
let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(*id)).await?;
|
||||
let n = client
|
||||
let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(*id)).await?;
|
||||
let n = tx
|
||||
.execute(
|
||||
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||
&[&new_slug, &id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
.map_err(AppError::tx)?;
|
||||
restored += n;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(AppError::tx)?;
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
|
||||
@ -40,15 +40,32 @@ pub async fn update_post(
|
||||
{
|
||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
// 重新渲染 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::<String>
|
||||
} 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<String> = client
|
||||
let old_slug: Option<String> = 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::<String>
|
||||
} 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<String> = {
|
||||
@ -147,7 +148,8 @@ pub async fn update_post(
|
||||
};
|
||||
|
||||
// 更新文章主表。
|
||||
tx.execute(
|
||||
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",
|
||||
&[
|
||||
@ -166,6 +168,15 @@ pub async fn update_post(
|
||||
.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();
|
||||
|
||||
|
||||
@ -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::<std::net::IpAddr>().is_ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<String> {
|
||||
// 按逗号拆分并过滤空项,列表末尾是离服务端最近的代理。
|
||||
// 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 {
|
||||
fn ip_from_x_real_ip(value: &str) -> Option<String> {
|
||||
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<std::net::SocketAddr>,
|
||||
) -> 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_proxy_count) {
|
||||
if let Some(ip) = ip_from_x_forwarded_for(value, trusted) {
|
||||
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(ip) = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(ip_from_x_real_ip)
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
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<std::net::SocketAddr>,
|
||||
) -> 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<SocketAddr>,
|
||||
) -> String {
|
||||
get_client_ip_internal(headers, trusted, peer)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 trimmed.starts_with('#') || trimmed.starts_with('/') {
|
||||
if allowed_schemes.contains(scheme_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
true
|
||||
if scheme_lower == "data" {
|
||||
return allow_data_uri && is_safe_data_uri(trimmed);
|
||||
}
|
||||
// 任何其它 scheme 均拒绝:file://、blob://、about:blank 等。
|
||||
return false;
|
||||
}
|
||||
// 无 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,<script>alert(1)</script>", &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();
|
||||
|
||||
@ -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<i32>,
|
||||
) -> Result<String, ServerFnError> {
|
||||
@ -66,8 +69,7 @@ pub async fn ensure_unique_slug(
|
||||
loop {
|
||||
// 查询当前候选 slug 是否已存在(排除指定文章 ID)。
|
||||
let exists = if let Some(exclude) = exclude_id {
|
||||
client
|
||||
.query_opt(
|
||||
tx.query_opt(
|
||||
"SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2",
|
||||
&[&candidate, &exclude],
|
||||
)
|
||||
@ -75,8 +77,7 @@ pub async fn ensure_unique_slug(
|
||||
.map_err(AppError::query)?
|
||||
.is_some()
|
||||
} else {
|
||||
client
|
||||
.query_opt(
|
||||
tx.query_opt(
|
||||
"SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL",
|
||||
&[&candidate],
|
||||
)
|
||||
|
||||
@ -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<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// 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<u8>, 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(' ');
|
||||
|
||||
@ -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<String>,
|
||||
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<String>) -> 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}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<NavItemConfig>, 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<NavItemConfig>, 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<NavItemConfig>, 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<dyn FnMut(_)>);
|
||||
|
||||
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),
|
||||
"✕"
|
||||
}
|
||||
|
||||
@ -19,9 +19,9 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||
.parse::<tokio_postgres::Config>()
|
||||
.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<Pool> = 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")
|
||||
});
|
||||
|
||||
@ -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)),
|
||||
);
|
||||
|
||||
// 静态资源路由:图片文件服务,支持动态裁剪/旋转/格式转换
|
||||
|
||||
@ -28,6 +28,7 @@ static MINIFY_CACHE: std::sync::LazyLock<Cache<String, String>> =
|
||||
/// 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,11 +42,23 @@ pub async fn layer(request: Request, next: Next) -> Response {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 命中缓存则直接返回
|
||||
let cache_key = uri.to_string();
|
||||
// 不缓存错误响应与后台管理页面,避免把错误页或敏感管理界面扩散。
|
||||
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();
|
||||
let bytes = match body.collect().await {
|
||||
@ -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);
|
||||
|
||||
// 写入缓存(忽略失败)
|
||||
// 仅对成功且非后台页面写入缓存。
|
||||
if should_cache {
|
||||
let _ = MINIFY_CACHE.insert(cache_key, minified.clone()).await;
|
||||
}
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(parts.status)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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() }
|
||||
|
||||
// 如果文章设置了封面图,则渲染封面组件。
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user