Compare commits

...

30 Commits

Author SHA1 Message Date
xfy
790002181a docs(env): add APP_BASE_URL and other security vars to .env.example
Some checks failed
CI / check (push) Failing after 12m34s
CI / build (push) Has been skipped
Task 1.1 疏漏:只更新了 AGENTS.md,漏了 .env.example。补上本次 review 引入及
原先缺失的 security 变量:
- APP_BASE_URL(CSRF 校验的可信 origin,1.1 引入)
- STATEMENT_TIMEOUT_SECS(慢查询超时,2.4 引入)
- COOKIE_SECURE / TRUSTED_PROXY_COUNT(原先就缺,一并补全)
2026-06-18 14:24:09 +08:00
xfy
60c51f44ab fix(posts): move rebuild SELECT into transaction with FOR UPDATE
Review 发现:SELECT 在事务外读 rows,事务内 UPDATE 时若并发编辑了该 post,
会用旧 content_md 覆盖新内容(非可重复读)。改为 SELECT 移入事务并加
FOR UPDATE,锁住待处理行直到 UPDATE 完成,消除丢失更新。

权衡:FOR UPDATE 锁最多 500 行直到事务结束,阻塞并发编辑;rebuild 是 admin
运维操作,可接受。
2026-06-18 14:17:20 +08:00
xfy
c7d8a5e67f fix(comments): use advisory lock to fully eliminate concurrent duplicate submissions
Review 发现:仅靠普通 SELECT+事务在 Read Committed 下无法阻止并发重复(两个
事务都看不到对方未提交的 INSERT)。改用 pg_advisory_xact_lock 以 content_hash
派生的 key 加事务级排他锁,使相同内容的并发请求排队,第二个查重必然命中第一个。

- 查重前加 pg_advisory_xact_lock(hashtext 前16位)
- Markdown 渲染/IP/UA 提取等纯计算移出事务,缩短关键排他锁窗口
- 注释准确描述:从「缩小窗口」改为「彻底消除并发重复」
2026-06-18 14:15:19 +08:00
xfy
57488f5c40 style(csrf): replace redundant closures with function references (clippy) 2026-06-18 13:45:32 +08:00
xfy
8f288c60da fix(comments): rate-limit check_pending_status to prevent status enumeration
该接口供访客轮询自己刚提交评论的审核状态,故不加 admin 鉴权(会破坏合法
轮询);改为加 strict 限流(对 unknown IP 降级宽松桶),阻止批量枚举评论
状态(L3)。复用 check_strict_limit,与其它敏感接口一致。
2026-06-18 13:43:57 +08:00
xfy
b34803e57d fix(search): drop ineffective trgm GIN index; correct misleading comment
ILIKE '%...%' 双侧通配符无法命中 trgm GIN(仅前缀模式命中),索引建了等于
白建且误导(L1)。删除以避免误导,搜索暂靠 LIMIT + 限流兜底;tsvector 全文
检索作为后续独立升级。
2026-06-18 13:42:23 +08:00
xfy
d1e08ec402 fix(image): write disk cache atomically via temp-file + rename
.dat/.ct 改为先写 .tmp 再 rename,避免并发请求读到内容与 content-type
错配的半成品文件(L5)。写失败或 rename 失败时清理临时文件与目标。
2026-06-18 13:40:48 +08:00
xfy
3723cd03f9 fix(image): add canonicalize prefix check to is_path_safe for defense in depth
子串检查之外,对已存在文件做 canonicalize 前缀校验,确认解析后路径仍在
uploads 目录内,抵御符号链接等绕过(L4)。文件或 uploads 目录不存在时
只靠第一层校验(交由后续读取报 404)。函数改为 async,调用点与测试同步更新。
2026-06-18 13:39:46 +08:00
xfy
7a6e9350fe fix(image): reject undecodable images with 422; cap raw file size at 20MB
- decode 失败(WebP 与其他格式)不再降级返回原始字节,防止构造的畸形文件以
  图片 content-type 返回任意内容(M3)
- 原始分支读前查 metadata,超 20MB 返回 413,避免超大文件撑爆内存
2026-06-18 13:38:03 +08:00
xfy
22e883c6d9 fix(image): add X-Content-Type-Options: nosniff to all image responses
304 与 200 两个分支都附加 nosniff,防止浏览器对 content-type 错配的图片字节
做 MIME sniff(M2)。配合原始文件分支按扩展名决定 content-type 的行为做纵深防御。
2026-06-18 13:36:39 +08:00
xfy
1e2e3c9332 perf(db): add statement_timeout; skip retry on pool Timeout errors
- 连接配置 statement_timeout(默认 30s,STATEMENT_TIMEOUT_SECS 可调),
  防慢查询长时间占用连接拖垮池(L6)
- get_conn 对 Timeout(池满)错误立即返回不再 sleep 重试,避免雪崩;
  仅 Backend/Postgres 错误才退避重试
2026-06-18 13:35:33 +08:00
xfy
179897ba6f fix(migrations): make all migrations idempotent; distinguish applied vs error
- 006: ADD COLUMN 补 IF NOT EXISTS
- 002: 5 个索引(status_published/slug_unique/post_tags_post/post_tags_tag/cover)补 IF NOT EXISTS
- migrate.sh: 区分「已应用」与「真出错」,真错误时打印输出并中止,不再静默吞掉(M6)

已验证:第二次运行全部 OK(幂等),不再出现重复对象错误。
2026-06-18 13:34:07 +08:00
xfy
71d4126e94 fix(posts): wrap rebuild_content_html in single transaction
整批 UPDATE 纳入事务,任一写入失败整批回滚,避免产生「部分文章已重建」的
中间态(M5)。渲染失败(spawn_blocking panic)仍跳过该条不进入事务操作。
失败时返回 rebuilt:0 明确告知整批回滚。
2026-06-18 13:32:55 +08:00
xfy
82ab190e0d fix(comments): make duplicate-check atomic with transaction; index content_hash
查重 SELECT 与 INSERT 包进同一事务,串行化并发请求缩小重复窗口(M4);
重复时 rollback 空事务。content_hash 加索引(非唯一,避免误杀不同作者
发相同短内容的合法场景)加速 5 分钟窗口查重,原先全表扫。
2026-06-18 13:31:37 +08:00
xfy
1c6974ca68 fix(auth): run dummy Argon2 verify for non-existent users to prevent timing enumeration
用户不存在时执行一次固定合法哈希的 verify(必然失败),抹平与「密码错误」
路径的时序差,防止通过响应时间枚举账号(L2)。dummy 哈希走 spawn_blocking,
与真实校验路径耗时一致;响应消息保持一致。
2026-06-18 13:29:55 +08:00
xfy
05c01ccebe fix(session): serialize session-limit enforcement with row lock
login 的 COUNT→DELETE→INSERT 改为事务内执行,并对 users 行加 FOR UPDATE,
串行化同一用户的并发登录,消除超出 MAX_SESSIONS_PER_USER 的竞态(M1)。

锁定 users 行而非 sessions 表,粒度最小;commit 后无残留 DB 操作。
2026-06-18 13:27:49 +08:00
xfy
53bfb1b7c0 feat(session): invalidate all sessions on role/status change via generation
users 表加 session_generation 列。get_user_by_token 缓存命中后回查 DB
generation,不匹配则逐出缓存并视为未登录,消除降级/封禁用户的权限残留
窗口(H2)。新增 invalidate_user_sessions 用于 bump generation,当前仓库
无运行时角色变更入口,作为未来用户管理功能的基础设施预留。

- 迁移 012:ADD COLUMN IF NOT EXISTS session_generation INT DEFAULT 0
- User/SessionUser 同步加字段,From<User> 如实传递
- 缓存校验走主键查询,亚毫秒级
2026-06-18 13:26:09 +08:00
xfy
82a3c12940 feat(security): add Origin-based CSRF protection for write endpoints
对所有 POST/PUT/PATCH/DELETE 请求校验 Origin(回退 Referer)等于本站,
堵住 login CSRF 与未来 GET 化写接口的盲区(SameSite=Lax 覆盖不到)。

- 新增 src/api/csrf.rs:纯函数 origin 解析(不引入 url crate)+ axum 中间件
- 挂载到 upload 路由与 Dioxus app 路由(最外层,先于超时/压缩)
- APP_BASE_URL 配置可信域名;未设置时回退 Host + X-Forwarded-Proto
- GET/OPTIONS 放行;拿不到本站 origin 时放行避免误杀
- 9 个单测覆盖写方法识别、origin 标准化、默认端口省略、头解析回退
2026-06-18 13:22:59 +08:00
xfy
00478e4a1a perf(rate-limit): use lenient bucket when client IP is unknown
TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端,
get_client_ip 返回 "unknown",所有匿名请求共用严格桶(1 req/s, burst 5),
正常用户的高频请求被误杀。check_strict_limit 现在对 unknown IP 改走
宽松桶(30 req/s, burst 100,可通过 RATE_LIMIT_UNKNOWN_* 调整)。
配好反向代理后走真实 IP,仍命中严格桶。新增 2 个 serial 测试锁定两路行为。
2026-06-18 11:28:06 +08:00
xfy
9986d1ce4e perf(auth): compile email regex once via LazyLock
auth.rs 与 comments/helpers.rs 原先每次校验都 Regex::new 重新编译。
改为 LazyLock 全局静态,正则只编译一次。
2026-06-18 11:25:15 +08:00
xfy
3d187382cc perf(sanitizer): staticize allowlists with LazyLock to avoid per-call allocation
default_allowed_tags / clean_content_tags / default_allowed_schemes
原本每次调用都新建 HashSet 并逐个 insert;sanitize() 还 clone 一份。
改为 LazyLock 静态集合,SanitizerConfig 直接持有 &'static 引用,
评论白名单 COMMENT_ALLOWED_TAGS 在默认集合上派生。18 个 sanitizer
测试全部通过,XSS/URL 过滤行为不变。
2026-06-18 11:23:30 +08:00
xfy
753525fb41 perf(upload): offload GIF/WebP raw image validation to spawn_blocking
GIF 走 image::load_from_memory 会完整解码动画帧,在 async 上下文阻塞
worker。移到 spawn_blocking,JoinError 兜底任务失败。
2026-06-18 11:19:34 +08:00
xfy
033b89ccb8 perf(posts): offload Markdown rendering to spawn_blocking
create/update/rebuild 三处 Markdown 渲染(含 syntect 高亮)移到阻塞
线程池。rebuild 用 spawn_blocking 的 JoinError 替代 catch_unwind 捕获
渲染 panic,避免单条记录拖垮整批。
2026-06-18 11:18:36 +08:00
xfy
45e92795de refactor(markdown): unify parser options across TOC and HTML passes
原先第一遍用 Options::all() 收集 heading,第二遍只用 ENABLE_TABLES
生成 HTML,两遍对扩展语法的处理不一致。统一为 Options::all()。
2026-06-18 11:16:58 +08:00
xfy
62600a6687 perf(auth): offload Argon2 hash/verify to spawn_blocking
Argon2 是 memory-hard 计算,登录/注册时同步执行会阻塞 Tokio worker
数百毫秒。改为在阻塞线程池执行,JoinError 兜底任务 panic。
2026-06-18 11:16:13 +08:00
xfy
79cb809010 perf(posts): add optional pagination to get_posts_by_tag, fix total count
- get_posts_by_tag 现接受 page/per_page 可选参数;两者均 None 时返回全部
  (上限 200,用于无翻页 UI 的标签详情页),均提供时走标准分页。
- 修正 total:不再用 posts.len(),改为真实 COUNT(*),即使被 LIMIT 截断
  也返回完整计数。
- 新增 CacheKey::PostsByTagPage 分页缓存键,与不分页键 PostsByTag 共存。
- 前端 tags.rs 传 (None, None) 保持原全部展示行为。
2026-06-18 11:14:56 +08:00
xfy
2dda168b19 perf(db): switch pool recycling method from Verified to Fast
Verified 每次取连接都会额外发 SELECT 1 验证;Fast 直接复用,
依赖 tokio-postgres 在使用时自然报错,由 get_conn 的退避重试兜底。
2026-06-18 11:08:14 +08:00
xfy
54f7bd481a perf(db): replace fixed 2s retry with exponential backoff + jitter
get_conn 现在按 base*2^attempt + 随机 jitter 退避(200ms 起步),
避免连接池抖动时多请求同步重试形成惊群。
2026-06-18 11:06:57 +08:00
xfy
104ff427f9 perf(db): add exponential backoff retry helper with tests
引入 src/db/retry.rs,提供 base * 2^attempt + jitter 的退避序列,
取代 pool.rs 中固定 2s 重试。MAX_RETRIES 将在下一个提交中被 get_conn 使用。
2026-06-18 11:05:22 +08:00
xfy
c6161764a9 perf(db): add indexes for trash and admin post list queries
- idx_posts_deleted_at: 回收站查询 WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC
- idx_posts_created_at_admin: 管理后台全量列表 WHERE deleted_at IS NULL ORDER BY created_at DESC
2026-06-18 11:02:13 +08:00
32 changed files with 967 additions and 253 deletions

View File

@ -11,6 +11,19 @@ RATE_LIMIT_UPLOAD_BURST=15
RATE_LIMIT_IMAGE_PER_SEC=10
RATE_LIMIT_IMAGE_BURST=50
# Security
# Trusted origin for CSRF checks on write requests (POST/PUT/PATCH/DELETE).
# Set to your production origin, e.g. https://your-domain.example.
# Unset: falls back to the request Host header + X-Forwarded-Proto (behind a reverse proxy).
APP_BASE_URL=
# Set true/1/yes to add the Secure flag to the session cookie (enable in HTTPS production).
COOKIE_SECURE=false
# Number of reverse proxies in front of the app; used to extract the real client IP
# from X-Forwarded-For. 0 when serving directly; 1 behind one proxy (e.g. nginx/Caddy).
TRUSTED_PROXY_COUNT=0
# Per-query timeout in seconds; slow queries are canceled to protect the connection pool.
STATEMENT_TIMEOUT_SECS=30
# WebP encoding configuration
# Quality: 0.0 (smallest) to 100.0 (best), default 85.0
WEBP_QUALITY=85.0

View File

@ -42,6 +42,7 @@ RATE_LIMIT_UPLOAD_BURST=15
RATE_LIMIT_IMAGE_PER_SEC=10
RATE_LIMIT_IMAGE_BURST=50
DB_POOL_SIZE=20 # database connection pool size
STATEMENT_TIMEOUT_SECS=30 # per-query timeout; slow queries are canceled to protect the pool
SSR_CACHE_SECS=3600 # incremental SSR cache TTL
```
@ -50,6 +51,7 @@ Session / security tuning:
```
COOKIE_SECURE=false # set true/1/yes to add Secure flag to session cookie
TRUSTED_PROXY_COUNT=0 # number of reverse proxies in front of the app; used to extract real client IP from X-Forwarded-For
APP_BASE_URL= # e.g. https://your-domain.example — trusted origin for CSRF checks on write requests; unset falls back to Host header + X-Forwarded-Proto
```
Run migrations before first dev server start:

View File

@ -66,10 +66,20 @@ echo "$MIGRATION_FILES" | sort | while IFS= read -r file; do
filename=$(basename "$file")
echo -n "[$filename] ... "
if psql "$DATABASE_URL" -f "$file" > /dev/null 2>&1; then
# 区分「已应用」与「真出错」迁移本身已幂等IF NOT EXISTS正常应返回 0。
# 非零退出码视为真错误打印输出并中止避免静默吞错M6
err_output=$(psql "$DATABASE_URL" -f "$file" 2>&1 >/dev/null)
rc=$?
if [[ $rc -eq 0 ]]; then
echo "OK"
elif echo "$err_output" | grep -qiE "already exists|duplicate|multiple primary keys"; then
echo "SKIPPED (already applied)"
else
echo "SKIPPED (already applied or error)"
echo "FAIL"
echo "$err_output" | head -5 | sed 's/^/ /'
echo "Migration aborted due to error in $filename"
exit 1
fi
done

View File

@ -20,8 +20,8 @@ CREATE TABLE IF NOT EXISTS posts (
CONSTRAINT posts_status_check CHECK (status IN ('draft', 'published'))
);
CREATE INDEX idx_posts_status_published ON posts(status, published_at DESC) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_posts_status_published ON posts(status, published_at DESC) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS post_tags (
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX idx_post_tags_post ON post_tags(post_id);
CREATE INDEX idx_post_tags_tag ON post_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_post_tags_post ON post_tags(post_id);
CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id);
-- 为封面图添加索引
CREATE INDEX idx_posts_cover ON posts(cover_image) WHERE cover_image IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_posts_cover ON posts(cover_image) WHERE cover_image IS NOT NULL;

View File

@ -1 +1 @@
ALTER TABLE posts ADD COLUMN toc_html TEXT;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS toc_html TEXT;

View File

@ -0,0 +1,13 @@
-- 性能审计补充索引。
-- 所有索引均为 CREATE INDEX IF NOT EXISTS对已有数据库安全幂等。
-- 回收站查询trash.rs 中大量 WHERE deleted_at IS NOT NULL ... ORDER BY deleted_at DESC。
-- 配合 list_deleted_posts 的 ORDER BY deleted_at DESC 分页。
CREATE INDEX IF NOT EXISTS idx_posts_deleted_at
ON posts(deleted_at DESC) WHERE deleted_at IS NOT NULL;
-- 管理后台列表 list_postsWHERE deleted_at IS NULL ORDER BY created_at DESC。
-- 注意 002_posts.sql 已有 idx_posts_status_published仅 published 分页),
-- 这里补充未按 status 过滤的管理后台全量列表路径。
CREATE INDEX IF NOT EXISTS idx_posts_created_at_admin
ON posts(created_at DESC) WHERE deleted_at IS NULL;

View File

@ -0,0 +1,6 @@
-- 会话世代号:用户角色/封禁状态变更时 bump 此列,使该用户所有已签发 session
-- 立即失效get_user_by_token 校验世代不匹配则视为未登录)。
-- 默认 0向后兼容。
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_generation INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN users.session_generation IS '会话世代号,变更时 +1 使旧 session 失效';

View File

@ -0,0 +1,5 @@
-- 评论内容哈希索引,加速 5 分钟窗口内的重复检测查询。
-- 注意不加 UNIQUE 约束content_hash 基于 parent_id+author+content
-- 不同作者发相同内容(如"顶"、"+1")是合法的,唯一约束会误杀。
CREATE INDEX IF NOT EXISTS idx_comments_content_hash
ON comments(content_hash);

View File

@ -0,0 +1,5 @@
-- 删除对 ILIKE '%...%'(双侧通配符)无效的 trgm GIN 索引L1
-- pg_trgm 的 GIN 索引只在前缀模式('xxx%')命中,双侧模糊匹配无法利用它,
-- 索引建了等于白建且误导。搜索改由 LIMIT + 限流兜底的全表扫承担;
-- 后续可升级为 tsvector 全文检索(独立大改动)。
DROP INDEX IF EXISTS idx_posts_search_trgm;

View File

@ -34,10 +34,14 @@ fn validate_username(username: &str) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "server")]
static EMAIL_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
});
#[cfg(feature = "server")]
fn validate_email(email: &str) -> Result<(), String> {
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
if !re.is_match(email) {
if !EMAIL_REGEX.is_match(email) {
return Err("邮箱格式不正确".to_string());
}
Ok(())
@ -113,8 +117,14 @@ pub async fn register(
let client = get_conn().await.map_err(AppError::db_conn)?;
let password_hash =
password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?;
// Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。
let pw_for_hash = password.clone();
let password_hash = tokio::task::spawn_blocking(move || {
password::hash_password(&pw_for_hash)
})
.await
.map_err(|_| AppError::Internal("密码处理任务失败"))?
.map_err(|_| AppError::Internal("密码处理失败"))?;
// 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。
// 若已有 admin 或用户名/邮箱冲突RETURNING 将返回空。
@ -180,7 +190,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
}
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let mut client = get_conn().await.map_err(AppError::db_conn)?;
let row = match client
.query_opt(
@ -191,6 +201,16 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
{
Ok(Some(row)) => row,
Ok(None) => {
// 用户不存在时也执行一次 Argon2 verify抹平「用户不存在」与
// 「密码错误」的响应时序差防止通过响应时间枚举账号L2
// 用固定合法哈希做 verify必然失败耗时与真实校验一致。
const DUMMY_HASH: &str =
"$argon2id$v=19$m=19456,t=2,p=1$j3rNaAXzdExYaL94WBWtfg$n1S75LUQKaYJwaRl5bkFF/f/N1tLfRYR/7TuQxKP94c";
let dummy_pw = password.clone();
let _ = tokio::task::spawn_blocking(move || {
crate::auth::password::verify_password(&dummy_pw, DUMMY_HASH)
})
.await;
return Ok(AuthResponse {
success: false,
message: "Invalid credentials".to_string(),
@ -203,7 +223,14 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
};
let password_hash: String = row.get("password_hash");
let valid = password::verify_password(&password, &password_hash)
// Argon2 校验同样在 spawn_blocking 中执行。
let pw_for_verify = password.clone();
let hash_for_verify = password_hash.clone();
let valid = tokio::task::spawn_blocking(move || {
password::verify_password(&pw_for_verify, &hash_for_verify)
})
.await
.map_err(|_| AppError::Internal("密码处理任务失败"))?
.map_err(|_| AppError::Internal("密码处理失败"))?;
if !valid {
@ -225,8 +252,15 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
.unwrap_or(5)
.max(1);
// 查询当前活跃会话数,超出限制时删除最早的一条。
let session_count: i64 = client
// 用事务 + 对 users 行加 FOR UPDATE 锁,串行化同一用户的并发登录,
// 避免 COUNT→DELETE→INSERT 之间的竞态导致超出上限M1
let tx = client.transaction().await.map_err(AppError::query)?;
// 锁住该用户行,并发登录在此排队。
tx.execute("SELECT 1 FROM users WHERE id = $1 FOR UPDATE", &[&user_id])
.await
.map_err(AppError::query)?;
let session_count: i64 = tx
.query_one(
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
&[&user_id],
@ -236,8 +270,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
.get(0);
if session_count >= max_sessions {
client
.execute(
tx.execute(
"DELETE FROM sessions WHERE id IN (
SELECT id FROM sessions
WHERE user_id = $1 AND expires_at > NOW()
@ -250,14 +283,15 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
.map_err(AppError::query)?;
}
client
.execute(
tx.execute(
"INSERT INTO sessions (user_id, token_hash, user_agent, expires_at) VALUES ($1, $2, $3, $4)",
&[&user_id, &token_hash, &None::<String>, &expires_at],
)
.await
.map_err(AppError::query)?;
tx.commit().await.map_err(AppError::query)?;
let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure());
// 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
@ -318,19 +352,39 @@ pub struct CurrentUserResponse {
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
///
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN未命中时回查数据库并回填缓存。
/// 仅服务端内部使用,不会暴露给前端。
/// 缓存命中后仍回查 `users.session_generation`:若用户已被降级/封禁generation 被
/// bump缓存的旧 SessionUser.generation 不再匹配,此时逐出缓存并视为未登录,
/// 消除权限残留窗口(见 H2。仅服务端内部使用不会暴露给前端。
pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
let token_hash = session::hash_token(token);
if let Some(user) = crate::cache::get_session_user(&token_hash).await {
return Ok(Some(user));
if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
// 缓存命中后校验世代号bump 后该用户所有 session 应失效。
// 查询走主键,亚毫秒级,代价可接受。
let current_gen: Option<i32> = get_conn()
.await
.map_err(AppError::db_conn)?
.query_opt(
"SELECT session_generation FROM users WHERE id = $1",
&[&cached.id],
)
.await
.map_err(AppError::query)?
.map(|r| r.get::<_, i32>(0));
match current_gen {
Some(gen) if gen == cached.session_generation => return Ok(Some(cached)),
_ => {
// 世代不匹配或用户已删:逐出缓存,落入下方重新查询。
crate::cache::invalidate_session_user(&token_hash).await;
}
}
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
"SELECT u.id, u.username, u.email, u.role, u.created_at
"SELECT u.id, u.username, u.email, u.role, u.created_at, u.session_generation
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
@ -349,6 +403,7 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
email: row.get("email"),
role,
created_at: row.get("created_at"),
session_generation: row.get("session_generation"),
})
}
None => None,
@ -361,6 +416,26 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
Ok(user)
}
#[cfg(feature = "server")]
/// 使指定用户的所有 session 立即失效bump `session_generation`。
///
/// 用于角色降级、封禁、密码修改等场景。bump 后该用户所有已签发 session 在下次
/// `get_user_by_token` 时因世代不匹配被逐出缓存并视为未登录。内存缓存无需主动清,
/// 惰性逐出即可。当前仓库无运行时角色变更入口,本函数是为未来「用户管理」功能
/// 预备的基础设施,一旦引入降级/封禁的 server function必须在 UPDATE 后调用。
#[allow(dead_code)] // 预留给未来的用户管理功能(角色变更/封禁触发全量 session 失效)
pub async fn invalidate_user_sessions(user_id: i32) -> Result<(), ServerFnError> {
let client = get_conn().await.map_err(AppError::db_conn)?;
client
.execute(
"UPDATE users SET session_generation = session_generation + 1 WHERE id = $1",
&[&user_id],
)
.await
.map_err(AppError::query)?;
Ok(())
}
/// 获取当前登录用户的公开信息。
///
/// Dioxus server function注册在 `/api` 路径下。

View File

@ -31,6 +31,17 @@ pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem
return Ok(vec![]);
}
// 限流防高速遍历枚举评论状态L3。本接口供访客轮询自己刚提交的评论
// 审核状态,故不加 admin 鉴权;但 strict 限流(对 unknown IP 降级宽松桶)
// 足以阻止批量枚举。
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
let ip = crate::api::rate_limit::get_client_ip(&parts.headers);
if let Err(_msg) = crate::api::rate_limit::check_strict_limit(&ip) {
return Err(ServerFnError::new("请求过于频繁,请稍后再试"));
}
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let rows = client

View File

@ -92,7 +92,7 @@ pub async fn create_comment(
});
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// 确认目标文章存在且处于已发布状态。
let post_row = client
@ -196,44 +196,20 @@ pub async fn create_comment(
// 基于文章、父评论、作者与内容计算哈希,防止短时间重复提交。
let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
let dup: Option<i64> = client
.query_opt(
"SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
&[&post_id, &content_hash],
)
.await
.map_err(AppError::query)?
.map(|r| r.get(0));
if dup.is_some() {
return Ok(CommentResponse {
success: false,
message: "请勿重复提交".to_string(),
error_code: Some("duplicate".into()),
comment_id: None,
avatar_url: None,
depth: None,
});
}
// 将 Markdown 渲染为 HTML并通过 sanitizer 过滤危险标签。
// 在开事务前完成纯计算Markdown 渲染、字段转义、IP/UA 提取),避免
// 在事务持锁期间做无谓工作,缩短关键排他锁窗口。
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
// 对作者展示字段做 HTML 转义,避免 XSSURL 为空字符串时统一为 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();
Some(crate::api::rate_limit::get_client_ip(&parts.headers))
} else {
None
};
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
parts
@ -245,8 +221,43 @@ pub async fn create_comment(
None
};
// 查重与插入在同一事务内,并用 advisory lock 串行化相同内容的并发提交M4
// 仅靠普通 SELECT+事务在 Read Committed 下无法阻止并发重复(两个事务都看不到
// 对方未提交的 INSERTpg_advisory_xact_lock 以内容哈希派生的 key 加事务级
// 排他锁,使相同内容的并发请求在锁上排队,第二个提交时查重必然命中前一个。
// key 取 content_hash 前 16 个 hex 字符8 字节)解析为 i64。
let lock_key: i64 = i64::from_str_radix(&content_hash[..16], 16).unwrap_or(0);
let tx = client.transaction().await.map_err(AppError::query)?;
// 事务级 advisory 锁:随事务结束自动释放,无需显式 unlock。
tx.execute("SELECT pg_advisory_xact_lock($1)", &[&lock_key])
.await
.map_err(AppError::query)?;
let dup: Option<i64> = tx
.query_opt(
"SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
&[&post_id, &content_hash],
)
.await
.map_err(AppError::query)?
.map(|r| r.get(0));
if dup.is_some() {
// 重复:回滚(释放 advisory 锁)后返回。
tx.rollback().await.ok();
return Ok(CommentResponse {
success: false,
message: "请勿重复提交".to_string(),
error_code: Some("duplicate".into()),
comment_id: None,
avatar_url: None,
depth: None,
});
}
// 插入评论,默认状态为 pending等待管理员审核。
let row = client
let row = tx
.query_one(
"INSERT INTO comments \
(post_id, parent_id, depth, author_name, author_email, author_url, \
@ -270,6 +281,8 @@ pub async fn create_comment(
.await
.map_err(AppError::query)?;
tx.commit().await.map_err(AppError::query)?;
let comment_id: i64 = row.get(0);
// 根据邮箱生成 Gravatar 头像链接。

View File

@ -109,11 +109,15 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "server")]
static EMAIL_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
});
/// 校验评论作者邮箱格式。
#[cfg(feature = "server")]
pub fn validate_comment_email(email: &str) -> Result<(), String> {
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
if !re.is_match(email.trim()) {
if !EMAIL_REGEX.is_match(email.trim()) {
return Err("邮箱格式不正确".to_string());
}
Ok(())

200
src/api/csrf.rs Normal file
View File

@ -0,0 +1,200 @@
//! CSRF 防护:对写请求校验 Origin回退 Referer必须等于本站。
//!
//! SameSite=Lax 只在顶级 GET 导航时自动带 cookie对跨站 POST 不带 cookie
//! 挡住了大部分经典 CSRF。但存在两个 Lax 覆盖不到的盲区:
//! 1. 登录 CSRF攻击者诱导受害者登录攻击者账号Lax 不阻止「设置」cookie
//! 2. 未来若出现 GET 化写接口Lax 会在顶级 GET 导航时带 cookie。
//! 因此对所有写请求叠加 Origin 校验作为纵深防御。
//! 仅在 `feature = "server"` 时编译。
#[cfg(feature = "server")]
use axum::http::{HeaderMap, Method, Request};
/// 判断请求是否需要 CSRF 校验非简单方法POST/PUT/PATCH/DELETE需要。
#[cfg(feature = "server")]
fn is_write_method(method: &Method) -> bool {
matches!(
method,
&Method::POST | &Method::PUT | &Method::PATCH | &Method::DELETE
)
}
/// 从 `scheme://host[:port][/path][?query]` 提取标准化的 `scheme://host[:port]`
/// 端口为默认值http=80, https=443时省略。
///
/// 不引入 url crateOrigin 头本身就是 `scheme://host[:port]`(无路径),
/// Referer 需要剥离 path/query用简单的 split 即可。
#[cfg(feature = "server")]
fn normalize_origin(input: &str) -> String {
// 取 authority 之前的部分作为 scheme以及第一个 '/' 之前的部分作为 authority。
let (scheme, rest) = match input.split_once("://") {
Some(pair) => pair,
None => return input.to_string(),
};
// rest 形如 host[:port]/path?query去掉首个 '/' 及之后内容。
let authority = match rest.split_once('/') {
Some((auth, _)) => auth,
None => rest,
};
// 省略默认端口。
match authority.rsplit_once(':') {
Some((host, port)) if port == "80" || port == "443" => {
format!("{}://{}", scheme, host)
}
_ => format!("{}://{}", scheme, authority),
}
}
/// 从请求头解析来源站点Origin 优先,回退 Referer
///
/// 返回标准化的 `scheme://host[:port]`。两者都缺失时返回 None视为不可信
#[cfg(feature = "server")]
fn extract_origin(headers: &HeaderMap) -> Option<String> {
if let Some(origin) = headers.get(axum::http::header::ORIGIN) {
return origin.to_str().ok().map(normalize_origin);
}
headers
.get(axum::http::header::REFERER)
.and_then(|v| v.to_str().ok())
.map(normalize_origin)
}
/// 计算本站可信 origin优先 `APP_BASE_URL` 环境变量(生产域名),
/// 否则用请求 Host 头 + `X-Forwarded-Proto`(反代后)或 https 推导。
///
/// 返回 None 表示无法确定本站 origin此时放行避免误杀——CSRF 漏判
/// 是请求被拒,但拿不到本站 origin 时误杀合法请求代价更高,故保守放行)。
#[cfg(feature = "server")]
fn trusted_origin(headers: &HeaderMap) -> Option<String> {
if let Ok(base) = std::env::var("APP_BASE_URL") {
return Some(normalize_origin(&base));
}
let host = headers.get(axum::http::header::HOST)?.to_str().ok()?;
let proto = headers
.get("X-Forwarded-Proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("https");
Some(normalize_origin(&format!("{}://{}", proto, host)))
}
/// CSRF 校验中间件。
///
/// 对写方法校验请求来源等于本站;不匹配返回 403。GET/OPTIONS 等放行。
/// 拿不到本站 origin 或请求来源时放行(见 trusted_origin 注释)。
#[cfg(feature = "server")]
pub async fn csrf_middleware(
req: Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
if is_write_method(req.method()) {
let headers = req.headers().clone();
let trusted = trusted_origin(&headers);
let incoming = extract_origin(&headers);
let ok = match (&trusted, &incoming) {
(Some(t), Some(o)) => t == o,
// 拿不到本站 origin 或请求来源时放行。
_ => true,
};
if !ok {
return axum::response::Response::builder()
.status(axum::http::StatusCode::FORBIDDEN)
.body(axum::body::Body::empty())
.expect("static forbidden response is always valid");
}
}
next.run(req).await
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use axum::http::{HeaderMap, HeaderValue, Method};
#[test]
fn is_write_method_recognizes_writes() {
assert!(is_write_method(&Method::POST));
assert!(is_write_method(&Method::PUT));
assert!(is_write_method(&Method::PATCH));
assert!(is_write_method(&Method::DELETE));
assert!(!is_write_method(&Method::GET));
assert!(!is_write_method(&Method::OPTIONS));
assert!(!is_write_method(&Method::HEAD));
}
#[test]
fn normalize_strips_path_and_query() {
assert_eq!(
normalize_origin("https://example.com/a/b?c=1"),
"https://example.com"
);
}
#[test]
fn normalize_preserves_nondefault_port() {
assert_eq!(
normalize_origin("http://localhost:3000/x"),
"http://localhost:3000"
);
}
#[test]
fn normalize_drops_default_ports() {
assert_eq!(
normalize_origin("https://example.com:443/path"),
"https://example.com"
);
assert_eq!(
normalize_origin("http://example.com:80/path"),
"http://example.com"
);
}
#[test]
fn normalize_keeps_explicit_nondefault_https_port() {
assert_eq!(
normalize_origin("https://example.com:8443"),
"https://example.com:8443"
);
}
#[test]
fn normalize_plain_origin_no_path() {
assert_eq!(
normalize_origin("https://example.com"),
"https://example.com"
);
}
#[test]
fn extract_origin_prefers_origin_header() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::ORIGIN,
HeaderValue::from_static("https://example.com"),
);
assert_eq!(
extract_origin(&headers),
Some("https://example.com".to_string())
);
}
#[test]
fn extract_origin_falls_back_to_referer() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::REFERER,
HeaderValue::from_static("https://example.com/posts/1"),
);
// Referer 的路径被剥离,只保留 scheme://host。
assert_eq!(
extract_origin(&headers),
Some("https://example.com".to_string())
);
}
#[test]
fn extract_origin_returns_none_when_both_absent() {
let headers = HeaderMap::new();
assert_eq!(extract_origin(&headers), None);
}
}

View File

@ -206,6 +206,11 @@ fn image_response(
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
(header::CONTENT_TYPE, content_type),
// nosniff 防止浏览器对 content-type 错配的图片字节做 MIME sniffM2
(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
],
)
.into_response();
@ -218,6 +223,10 @@ fn image_response(
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
],
data,
)
@ -341,9 +350,10 @@ fn process_image_blocking(
img
}
Err(e) => {
tracing::warn!("WebP decode failed ({}), returning raw bytes", e);
let ct = content_type(original_format);
return Ok((data, ct));
// decode 失败不再降级返回原始字节(可能是构造的畸形文件,配合 nosniff
// 构成内容混淆面),直接报错让上层返回 422M3
tracing::warn!("WebP decode failed ({}), rejecting", e);
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
}
} else {
@ -353,9 +363,8 @@ fn process_image_blocking(
match reader.decode() {
Ok(img) => img,
Err(e) => {
tracing::warn!("Image decode failed ({}), returning raw bytes", e);
let ct = content_type(original_format);
return Ok((data, ct));
tracing::warn!("Image decode failed ({}), rejecting", e);
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
}
};
@ -364,16 +373,24 @@ fn process_image_blocking(
}
#[cfg(feature = "server")]
fn is_path_safe(path: &str) -> bool {
// Reject paths with parent directory references or null bytes
if path.contains("..") || path.contains('\0') {
/// 校验请求路径不会逃出 uploads 目录。
///
/// 两层校验:① 子串级拒绝 `..`/`\0`/绝对路径前缀;② 对已存在文件用 canonicalize
/// 确认解析后真实路径仍在 uploads 目录内(纵深防御,抵御符号链接等绕过)。
/// 文件不存在或 uploads 目录不存在时只做第一层校验(由后续 read 报 404
async fn is_path_safe(path: &str) -> bool {
if path.contains("..") || path.contains('\0') || path.starts_with('/') {
return false;
}
// Reject absolute paths
if path.starts_with('/') {
return false;
let candidate = std::path::Path::new("uploads").join(path);
let uploads_root = match tokio::fs::canonicalize("uploads").await {
Ok(p) => p,
Err(_) => return true, // uploads 目录不存在(测试环境),只靠第一层校验。
};
match tokio::fs::canonicalize(&candidate).await {
Ok(resolved) => resolved.starts_with(&uploads_root),
Err(_) => true, // 文件不存在,交由后续读取报 404。
}
true
}
#[cfg(feature = "server")]
@ -418,11 +435,31 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
.content_type
.to_str()
.unwrap_or("application/octet-stream");
if let Err(e) = tokio::fs::write(format!("{}.dat", base), &cached.data).await {
tracing::warn!("Failed to write disk cache data: {:?}", e);
// 原子写:先写 .tmp 再 rename避免并发请求读到 .dat 与 .ct 错配的半成品L5
let dat_path = format!("{}.dat", base);
let ct_path = format!("{}.ct", base);
let dat_tmp = format!("{}.dat.tmp", base);
let ct_tmp = format!("{}.ct.tmp", base);
// 两个临时文件都写成功后才 rename任一失败则清理半成品。
let writes_ok = tokio::fs::write(&dat_tmp, &cached.data).await.is_ok()
&& tokio::fs::write(&ct_tmp, ct_str).await.is_ok();
if !writes_ok {
let _ = tokio::fs::remove_file(&dat_tmp).await;
let _ = tokio::fs::remove_file(&ct_tmp).await;
tracing::warn!("Failed to write disk cache temp files at {}", base);
return;
}
if let Err(e) = tokio::fs::write(format!("{}.ct", base), ct_str).await {
tracing::warn!("Failed to write disk cache content type: {:?}", e);
let rename_dat = tokio::fs::rename(&dat_tmp, &dat_path).await;
let rename_ct = tokio::fs::rename(&ct_tmp, &ct_path).await;
if rename_dat.is_err() || rename_ct.is_err() {
// rename 失败:清理可能残留的临时文件与目标,避免读到错配内容。
let _ = tokio::fs::remove_file(&dat_tmp).await;
let _ = tokio::fs::remove_file(&ct_tmp).await;
tracing::warn!("Failed to atomically rename disk cache at {}", base);
}
}
@ -443,7 +480,7 @@ pub async fn serve_image(
return status.into_response();
}
if !is_path_safe(&path) {
if !is_path_safe(&path).await {
return StatusCode::FORBIDDEN.into_response();
}
@ -456,12 +493,19 @@ pub async fn serve_image(
// No processing params: return raw file with long-lived cache headers.
if params.is_empty() {
return match tokio::fs::read(&file_path).await {
// 原始分支也限制大小避免读取超大文件撑爆内存M3。上限 20MB
// 覆盖正常上传图(上传侧 MAX_FILE_SIZE=5MB拒绝异常大文件。
const MAX_RAW_BYTES: u64 = 20 * 1024 * 1024;
return match tokio::fs::metadata(&file_path).await {
Ok(meta) if meta.len() > MAX_RAW_BYTES => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
Ok(_) => match tokio::fs::read(&file_path).await {
Ok(data) => {
let ct = content_type(detect_format(&path));
image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers)
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
},
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
@ -638,26 +682,26 @@ mod tests {
assert!(params.validate().is_err());
}
#[test]
fn is_path_safe_normal() {
assert!(is_path_safe("images/photo.jpg"));
assert!(is_path_safe("2024/01/photo.png"));
#[tokio::test]
async fn is_path_safe_normal() {
assert!(is_path_safe("images/photo.jpg").await);
assert!(is_path_safe("2024/01/photo.png").await);
}
#[test]
fn is_path_safe_rejects_parent_dir() {
assert!(!is_path_safe("../etc/passwd"));
assert!(!is_path_safe("foo/../../bar"));
#[tokio::test]
async fn is_path_safe_rejects_parent_dir() {
assert!(!is_path_safe("../etc/passwd").await);
assert!(!is_path_safe("foo/../../bar").await);
}
#[test]
fn is_path_safe_rejects_null_bytes() {
assert!(!is_path_safe("foo\0bar"));
#[tokio::test]
async fn is_path_safe_rejects_null_bytes() {
assert!(!is_path_safe("foo\0bar").await);
}
#[test]
fn is_path_safe_rejects_absolute_path() {
assert!(!is_path_safe("/etc/passwd"));
#[tokio::test]
async fn is_path_safe_rejects_absolute_path() {
assert!(!is_path_safe("/etc/passwd").await);
}
#[test]

View File

@ -37,8 +37,12 @@ pub struct RenderedContent {
pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
// 两遍解析使用相同的 Options避免 TOC 收集与正文渲染对 Markdown 扩展语法
// (表格、删除线、脚注等)的处理不一致。
let opts = Options::all();
// 1. Parse markdown and collect headings for TOC
let parser = pulldown_cmark::Parser::new_ext(md, Options::all());
let parser = pulldown_cmark::Parser::new_ext(md, opts);
// (level, text, id)
let mut headings: Vec<(u8, String, String)> = Vec::new();
let mut current_heading: Option<(u8, String)> = None;
@ -80,7 +84,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let toc_html = generate_toc_html(&headings);
// 3. Generate HTML with heading anchors
let parser = pulldown_cmark::Parser::new_ext(md, Options::ENABLE_TABLES);
let parser = pulldown_cmark::Parser::new_ext(md, opts);
let mut html = String::new();
let mut heading_idx = 0;
let mut in_heading = false;

View File

@ -6,6 +6,8 @@
/// 认证相关的 Dioxus server function。
pub mod auth;
/// CSRF 防护中间件。
pub mod csrf;
/// 评论相关接口。
pub mod comments;
/// 应用错误类型与转换。

View File

@ -76,8 +76,13 @@ pub async fn create_post(
{
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// 渲染 Markdown 为 HTML并提取目录。
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
// Markdown 渲染(含 syntect 高亮)是 CPU 密集任务,移到阻塞线程池执行。
let md_for_render = content_md.clone();
let rendered = tokio::task::spawn_blocking(move || {
crate::api::markdown::render_markdown_enhanced(&md_for_render)
})
.await
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
let content_html = rendered.html;
let toc_html = if rendered.toc_html.is_empty() {
None::<String>

View File

@ -235,12 +235,37 @@ pub async fn list_deleted_posts(
/// 获取指定标签下的已发布文章列表。
///
/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。
/// 分页参数为可选:
/// - `page` 与 `per_page` 均为 `None` 时返回该标签下全部已发布文章(上限 200
/// 用于无分页 UI 的标签详情页。
/// - 两者均提供时走标准分页(经 `clamp_pagination` 钳制)。
/// 结果缓存于按标签的分页键空间。
#[server(GetPostsByTag, "/api")]
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
pub async fn get_posts_by_tag(
tag_name: String,
page: Option<i32>,
per_page: Option<i32>,
) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")]
{
if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await
// 仅当两个分页参数都提供时才走分页路径;任一为 None 视为不分页。
let (page, per_page) = match (page, per_page) {
(Some(p), Some(pp)) => (Some(p), Some(pp)),
_ => (None, None),
};
let client = get_conn().await.map_err(AppError::db_conn)?;
if let (Some(page), Some(per_page)) = (page, per_page) {
// 分页路径:钳制参数,走分页缓存键。
let (page, per_page) = clamp_pagination(page, per_page);
let cache_key = crate::cache::CacheKey::PostsByTagPage {
tag: tag_name.clone(),
page,
per_page,
};
if let Some((cached_posts, cached_total)) =
crate::cache::get_posts_by_tag_paged(&cache_key).await
{
return Ok(PostListResponse {
posts: cached_posts,
@ -248,9 +273,73 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
});
}
let client = get_conn().await.map_err(AppError::db_conn)?;
// 标签下已发布文章总数。
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
&[&tag_name],
)
.await
.map_err(AppError::query)?
.get(0);
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
let limit = per_page as i64;
let rows = client
.query(
"SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.published_at, p.created_at, p.updated_at, p.cover_image,
p.word_count, p.reading_time,
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
LEFT JOIN post_tags pt2 ON p.id = pt2.post_id
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
LIMIT $2 OFFSET $3",
&[&tag_name, &limit, &offset],
)
.await
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
posts.push(row_to_post_list_item(row));
}
crate::cache::set_posts_by_tag_paged(&cache_key, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
} else {
// 不分页路径:返回全部(上限 200用于无翻页 UI 的标签详情页。
if let Some((cached_posts, cached_total)) =
crate::cache::get_posts_by_tag(&tag_name).await
{
return Ok(PostListResponse {
posts: cached_posts,
total: cached_total,
});
}
// 真实总数(即使被 LIMIT 截断也返回完整计数)。
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
&[&tag_name],
)
.await
.map_err(AppError::query)?
.get(0);
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。
let rows = client
.query(
"SELECT
@ -277,12 +366,11 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
posts.push(row_to_post_list_item(row));
}
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。
// 若后续增加分页,应改为 COUNT(*) 查询。
let total = posts.len() as i64;
// total 为真实 COUNT(*),不再用 posts.len()。
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
}
}
#[cfg(not(feature = "server"))]
{

View File

@ -32,33 +32,44 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// 根据参数构造 WHERE 条件,限制单次处理数量。
// SELECT 在事务内并加 FOR UPDATE锁住待处理行直到 UPDATE 完成,避免
// 并发编辑造成非可重复读review 发现):事务外读到的 content_md 可能在
// UPDATE 前被并发请求修改,导致用旧内容覆盖新内容。
let query = if rebuild_all {
format!(
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
)
} else {
format!(
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL AND content_html IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL AND content_html IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
)
};
let rows = client.query(&query, &[]).await.map_err(AppError::query)?;
let mut rebuilt: u64 = 0;
let mut failed: u64 = 0;
let mut errors: Vec<String> = Vec::new();
// 整批 SELECT + UPDATE 纳入单事务:中途断连或写入失败整批回滚,避免产生
// 「部分文章已重建」的中间态M5FOR UPDATE 锁住的行随事务结束释放。
let tx = client.transaction().await.map_err(AppError::query)?;
let rows = tx.query(&query, &[]).await.map_err(AppError::query)?;
for row in &rows {
let id: i32 = row.get(0);
let content_md: String = row.get(1);
// 捕获 Markdown 渲染 panic避免单条记录导致整批失败。
let rendered = match std::panic::catch_unwind(|| {
crate::api::markdown::render_markdown_enhanced(&content_md)
}) {
// Markdown 渲染在阻塞线程池执行spawn_blocking 的 JoinError 自动捕获 panic
// 替代原先的 catch_unwind。
let md_for_render = content_md.clone();
let rendered = match tokio::task::spawn_blocking(move || {
crate::api::markdown::render_markdown_enhanced(&md_for_render)
})
.await
{
Ok(r) => r,
Err(_) => {
failed += 1;
@ -78,7 +89,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
let word_count = crate::utils::text::count_words(&content_md);
let reading_time = crate::utils::text::reading_time(word_count);
match client
match tx
.execute(
"UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
&[
@ -94,15 +105,26 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
Ok(_) => {
rebuilt += 1;
}
Err(_) => {
Err(e) => {
// 事务内任一写入失败会使事务进入 abort 状态,后续写入都会失败;
// 此时整批回滚,保证不产生中间态。
failed += 1;
if errors.len() < MAX_DISPLAY_ERRORS {
errors.push(format!("文章 #{id}: DB 写入失败"));
}
errors.push(format!("文章 #{id}: DB 写入失败(整批将回滚)"));
}
tracing::error!("rebuild UPDATE 失败,整批回滚: {:?}", e);
tx.rollback().await.ok();
return Ok(RebuildResult {
rebuilt: 0,
failed,
errors,
});
}
}
}
tx.commit().await.map_err(AppError::query)?;
// 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、
// 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。
if rebuilt > 0 {

View File

@ -61,7 +61,9 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
.replace('%', "\\%")
.replace('_', "\\_");
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
// 使用 ILIKE 做子串模糊匹配(双侧 %)。注意:此查询无法利用 trgm GIN
// 索引(仅前缀模式命中),走全表扫,靠 LIMIT 50 + search 限流兜底。
// 后续可升级为 tsvector 全文检索(独立大改动)。
let rows = client
.query(
"SELECT

View File

@ -40,8 +40,13 @@ 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);
// Markdown 渲染移到阻塞线程池执行。
let md_for_render = content_md.clone();
let rendered = tokio::task::spawn_blocking(move || {
crate::api::markdown::render_markdown_enhanced(&md_for_render)
})
.await
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
let content_html = rendered.html;
let toc_html = if rendered.toc_html.is_empty() {
None::<String>

View File

@ -61,6 +61,20 @@ static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::ne
)
});
#[cfg(feature = "server")]
/// 当无法识别真实客户端 IP"unknown")时使用的宽松限流桶。
///
/// TRUSTED_PROXY_COUNT=0默认Dioxus server function 拿不到 TCP 对端地址,
/// get_client_ip 会返回 "unknown",导致所有匿名请求共享同一个严格桶
/// 1 req/s, burst 5正常用户的高频请求被误杀。此桶阈值更高
/// 通过 env RATE_LIMIT_UNKNOWN_PER_SEC / RATE_LIMIT_UNKNOWN_BURST 可调。
static UNKNOWN_BUCKET_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_UNKNOWN_PER_SEC", 30))
.allow_burst(env_or("RATE_LIMIT_UNKNOWN_BURST", 100)),
)
});
#[cfg(feature = "server")]
/// 检查评论请求是否超出限流阈值。
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
@ -184,11 +198,23 @@ pub fn get_client_ip(headers: &http::HeaderMap) -> String {
#[cfg(feature = "server")]
/// 检查严格限流(注册、登录等敏感接口)。
///
/// 当 IP 为 "unknown"(无法识别真实客户端,通常是 TRUSTED_PROXY_COUNT=0
/// 且调用方为 Dioxus server function 时)改用宽松桶,避免所有匿名请求共享
/// 严格桶导致正常用户被误杀。生产环境配好 TRUSTED_PROXY_COUNT 后走真实 IP
/// 始终命中严格桶。
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
if ip == "unknown" {
UNKNOWN_BUCKET_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "服务繁忙,请稍后再试".to_string())
} else {
STRICT_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "请求过于频繁,请稍后再试".to_string())
}
}
#[cfg(feature = "server")]
@ -370,6 +396,43 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn check_strict_unknown_ip_uses_lenient_bucket() {
// "unknown" 桶 burst 为 100少量请求应全部放行不被严格桶误杀。
// 用 serial 隔离,因为 UNKNOWN_BUCKET_LIMITER 是全局状态。
for _ in 0..20 {
assert!(
super::check_strict_limit("unknown").is_ok(),
"unknown bucket should allow small bursts, not hit strict 1 req/s limit"
);
}
}
#[test]
#[serial_test::serial]
fn check_strict_real_ip_uses_strict_bucket() {
// 真实 IP 命中严格桶1 req/s, burst 5。连发超过 burst 应被限流。
// 用一个唯一的 IP 避免与其他测试状态冲突。
let unique_ip = "198.51.100.42";
let mut allowed = 0;
let mut blocked = false;
for _ in 0..50 {
match super::check_strict_limit(unique_ip) {
Ok(()) => allowed += 1,
Err(_) => blocked = true,
}
if blocked {
break;
}
}
assert!(blocked, "strict bucket should eventually block real IP burst");
assert!(
allowed <= 6,
"strict burst is 5, allowed should be <= 6, got {allowed}"
);
}
// 测试辅助函数:绕过环境变量读取,直接指定 trusted_proxy_count。
fn get_client_ip_with_trusted_and_peer(
headers: &HeaderMap,

View File

@ -10,9 +10,11 @@
use std::collections::HashSet;
#[cfg(feature = "server")]
fn default_allowed_tags() -> HashSet<&'static str> {
let mut set = HashSet::new();
for tag in [
use std::sync::LazyLock;
#[cfg(feature = "server")]
static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"a",
"abbr",
"acronym",
@ -88,24 +90,16 @@ fn default_allowed_tags() -> HashSet<&'static str> {
"ul",
"var",
"wbr",
] {
set.insert(tag);
}
set
}
])
});
#[cfg(feature = "server")]
fn clean_content_tags() -> HashSet<&'static str> {
let mut set = HashSet::new();
set.insert("script");
set.insert("style");
set
}
static CLEAN_CONTENT_TAGS: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| HashSet::from(["script", "style"]));
#[cfg(feature = "server")]
fn default_allowed_schemes() -> HashSet<&'static str> {
let mut set = HashSet::new();
for scheme in [
static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"bitcoin",
"ftp",
"ftps",
@ -131,11 +125,18 @@ fn default_allowed_schemes() -> HashSet<&'static str> {
"webcal",
"wtai",
"xmpp",
] {
set.insert(scheme);
}
])
});
#[cfg(feature = "server")]
/// 评论允许的标签:在默认集合基础上移除 img / details / summary。
static COMMENT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
let mut set = DEFAULT_ALLOWED_TAGS.clone();
set.remove("img");
set.remove("details");
set.remove("summary");
set
}
});
#[cfg(feature = "server")]
fn is_safe_data_uri(url: &str) -> bool {
@ -192,19 +193,19 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
#[cfg(feature = "server")]
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
struct SanitizerConfig {
allowed_tags: HashSet<&'static str>,
allowed_tags: &'static HashSet<&'static str>,
extra_generic_attrs: Vec<&'static str>,
extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
allowed_schemes: HashSet<&'static str>,
allowed_schemes: &'static HashSet<&'static str>,
allow_data_uri: bool,
link_rel: Option<&'static str>,
remove_tags: HashSet<&'static str>,
remove_tags: &'static HashSet<&'static str>,
}
#[cfg(feature = "server")]
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
let allowed_tags = config.allowed_tags.clone();
let remove_tags = config.remove_tags.clone();
let allowed_tags = config.allowed_tags;
let remove_tags = config.remove_tags;
let generic_attrs: HashSet<&str> = config
.extra_generic_attrs
.iter()
@ -251,7 +252,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
}
m
};
let allowed_schemes = config.allowed_schemes.clone();
let allowed_schemes = config.allowed_schemes;
let allow_data_uri = config.allow_data_uri;
let link_rel = config.link_rel;
@ -286,7 +287,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
if allowed_for_tag.contains(name_lower.as_str()) {
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
let val = attr.value();
if !is_safe_url(&val, &allowed_schemes, allow_data_uri) {
if !is_safe_url(&val, allowed_schemes, allow_data_uri) {
return Some(name);
}
}
@ -331,7 +332,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
/// 文章正文 HTML 清理:允许较完整的标签与 data URI外链添加 `noopener noreferrer`。
pub fn clean_html(input: &str) -> String {
let config = SanitizerConfig {
allowed_tags: default_allowed_tags(),
allowed_tags: &DEFAULT_ALLOWED_TAGS,
extra_generic_attrs: vec![
"class",
"aria-hidden",
@ -351,10 +352,10 @@ pub fn clean_html(input: &str) -> String {
("h5", vec!["id", "class"]),
("h6", vec!["id", "class"]),
],
allowed_schemes: default_allowed_schemes(),
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false,
link_rel: Some("noopener noreferrer"),
remove_tags: clean_content_tags(),
remove_tags: &CLEAN_CONTENT_TAGS,
};
sanitize(input, &config)
}
@ -362,13 +363,8 @@ pub fn clean_html(input: &str) -> String {
#[cfg(feature = "server")]
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI外链添加 `nofollow noopener`。
pub fn clean_comment_html(input: &str) -> String {
let mut tags = default_allowed_tags();
tags.remove("img");
tags.remove("details");
tags.remove("summary");
let config = SanitizerConfig {
allowed_tags: tags,
allowed_tags: &COMMENT_ALLOWED_TAGS,
extra_generic_attrs: vec![
"class",
"title",
@ -381,10 +377,10 @@ pub fn clean_comment_html(input: &str) -> String {
("a", vec!["class", "aria-hidden", "aria-label"]),
("span", vec!["class"]),
],
allowed_schemes: default_allowed_schemes(),
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false,
link_rel: Some("nofollow noopener"),
remove_tags: clean_content_tags(),
remove_tags: &CLEAN_CONTENT_TAGS,
};
sanitize(input, &config)
}
@ -464,26 +460,26 @@ mod tests {
#[test]
fn is_safe_url_allows_https() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(is_safe_url("https://example.com", &schemes, false));
assert!(is_safe_url("http://example.com", &schemes, false));
}
#[test]
fn is_safe_url_rejects_javascript() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
}
#[test]
fn is_safe_url_rejects_vbscript() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
}
#[test]
fn is_safe_url_data_uri_respects_flag_and_media_type() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 仅在显式允许且 media type 为图片时通过
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true));
@ -496,7 +492,7 @@ mod tests {
#[test]
fn is_safe_url_allows_relative_and_fragment() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 绝对路径
assert!(is_safe_url("/path/to/page", &schemes, false));
// 锚点
@ -505,7 +501,7 @@ mod tests {
#[test]
fn is_safe_url_empty_is_safe() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 空 URL如 img 无 src视为安全。
assert!(is_safe_url("", &schemes, false));
assert!(is_safe_url(" ", &schemes, false));
@ -513,7 +509,7 @@ mod tests {
#[test]
fn is_safe_url_allows_other_whitelisted_schemes() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// mailto / tel / ftp 等均在默认白名单中。
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
@ -522,14 +518,14 @@ mod tests {
#[test]
fn is_safe_url_rejects_scheme_with_whitespace() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
}
#[test]
fn is_safe_url_rejects_unknown_schemes() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 未知 scheme 默认拒绝。
assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false));
@ -539,7 +535,7 @@ mod tests {
#[test]
fn is_safe_url_scheme_matching_is_case_insensitive() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// scheme 大小写不敏感HTTPS 与 https 等价。
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));

View File

@ -205,8 +205,25 @@ pub async fn upload_image(
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()) {
// 对不经过重编码的格式做解码验证。GIF 走 image::load_from_memory 会完整解码,
// 移到阻塞线程池避免拖住 async 运行时。
if is_gif || is_webp {
let validate_data = data.to_vec();
let validate_mime = mime_type.clone();
let is_valid = tokio::task::spawn_blocking(move || {
validate_raw_image(&validate_data, validate_mime.as_str())
})
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"success": false,
"error": "图片校验任务失败"
})),
)
})?;
if !is_valid {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
@ -215,6 +232,7 @@ pub async fn upload_image(
})),
));
}
}
// GIF 与 WebP 保持原格式;其余格式尝试转 WebP。
let (final_data, final_ext) = if is_gif {

View File

@ -74,8 +74,10 @@ pub enum CacheKey {
AllTags,
/// 按 slug 查询的单篇文章。
PostBySlug(String),
/// 按标签查询的文章列表
/// 按标签查询的文章列表(不分页,返回全部)
PostsByTag(String),
/// 按标签查询的分页文章列表。
PostsByTagPage { tag: String, page: i32, per_page: i32 },
/// 文章统计信息。
PostStats,
/// 某篇文章下的评论列表。
@ -274,6 +276,18 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
.await;
}
/// 按标签+分页读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
TAG_POSTS_CACHE.get(key).await
}
/// 按标签+分页写入文章列表缓存。
#[cfg(feature = "server")]
pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
let _ = TAG_POSTS_CACHE.insert(key.clone(), (posts, total)).await;
}
/// 读取文章统计缓存。
#[cfg(feature = "server")]
pub async fn get_post_stats() -> Option<PostStats> {
@ -665,6 +679,7 @@ mod tests {
email: "cached@example.com".to_string(),
role: UserRole::Admin,
created_at: chrono::Utc::now(),
session_generation: 0,
};
let token_hash = "sha256_token_hash";

View File

@ -12,6 +12,10 @@
#[cfg(feature = "server")]
pub mod pool;
/// 连接获取的指数退避重试策略,仅在启用 server feature 时编译。
#[cfg(feature = "server")]
pub mod retry;
/// 占位连接池实现,仅在不启用 server feature 时编译。
///
/// `DummyPool` 是一个最小 stub它提供与真实连接池相同的公开接口形状

View File

@ -2,7 +2,7 @@
//!
//! 仅在启用 `server` feature 时编译,使用 deadpool-postgres 管理连接池,
//! 并通过 `std::sync::LazyLock` 在首次访问时延迟初始化全局连接池。
//! `get_conn` 失败时按固定 2 秒间隔进行简单重试,以应对瞬时连接失败。
//! `get_conn` 失败时按指数退避 + jitter 重试(见 `retry` 模块),以应对瞬时连接失败。
use std::sync::LazyLock;
use std::time::Duration;
@ -15,13 +15,24 @@ use tokio_postgres::NoTls;
/// 最大连接数可通过 `DB_POOL_SIZE` 环境变量调整,默认 20。
pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set");
let pg_cfg = db_url
let mut pg_cfg = db_url
.parse::<tokio_postgres::Config>()
.expect("Invalid DATABASE_URL format");
// 使用 Verified 回收策略,确保归还的连接仍然可用,避免 DB 重启后拿到死连接。
// statement_timeout防止单条慢查询如全表扫搜索长时间占用连接拖垮池。
// 默认 30s可由 STATEMENT_TIMEOUT_SECS 覆盖L6
let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(30);
// 通过 libpq options 传递 GUCtokio-postgres 在建连时执行。
pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000));
// 使用 Fast 回收策略:归还连接时不额外发 SELECT 1 验证,直接复用。
// Verified 在高并发下会为每次 get() 增加一次往返Fast 依赖 tokio-postgres
// 在使用时自然报错,由 get_conn 的重试层兜底。
let mgr_cfg = ManagerConfig {
recycling_method: RecyclingMethod::Verified,
recycling_method: RecyclingMethod::Fast,
};
let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
@ -40,31 +51,37 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
.expect("Failed to create database connection pool")
});
/// 最大重试次数。
const MAX_RETRIES: u32 = 3;
/// 每次重试之间的固定等待时间。
const RETRY_DELAY: Duration = Duration::from_secs(2);
/// 从全局连接池获取一个数据库连接,失败时按 `MAX_RETRIES` 进行重试。
/// 从全局连接池获取一个数据库连接,失败时按指数退避 + jitter 重试。
///
/// 若所有尝试均失败,则返回最后一次遇到的 PoolError。
/// 退避策略见 `retry::backoff_for`。仅对 Backend/Postgres 错误DB 不可达)重试;
/// Timeout池满直接返回让上层限流兜底避免雪崩L6
/// 若所有重试均失败,返回最后一次的 PoolError。
pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
use rand::Rng;
let mut last_err = None;
for attempt in 0..=MAX_RETRIES {
for attempt in 0..=crate::db::retry::MAX_RETRIES {
match DB_POOL.get().await {
Ok(conn) => return Ok(conn),
Err(e) => {
if attempt < MAX_RETRIES {
tracing::warn!(
"DB connection attempt {} failed, retrying in {:?}: {:?}",
attempt + 1,
RETRY_DELAY,
e
);
tokio::time::sleep(RETRY_DELAY).await;
}
// Timeout池满不重试快速失败让上层限流兜底避免雪崩。
// Backend/PostgresDB 不可达)才退避重试。
let is_timeout = matches!(e, deadpool_postgres::PoolError::Timeout(_));
last_err = Some(e);
if !is_timeout && attempt < crate::db::retry::MAX_RETRIES {
let jitter = rand::thread_rng().gen::<f64>();
let delay = crate::db::retry::backoff_for(attempt, jitter);
tracing::warn!(
"DB connection attempt {} failed (backend error), retrying in {:?}: {:?}",
attempt + 1,
delay,
last_err.as_ref().unwrap(),
);
tokio::time::sleep(delay).await;
} else if is_timeout {
// 池满:立即返回,不再 sleep。
break;
}
}
}
}

57
src/db/retry.rs Normal file
View File

@ -0,0 +1,57 @@
//! 数据库连接获取的指数退避重试策略。
//!
//! 取代 pool.rs 中固定 2s 间隔的重试:每次重试间隔 = base * 2^attempt
//! 再叠加 [0, base) 的随机 jitter避免多请求同步重试形成惊群。
//! 仅在 `feature = "server"` 时编译。
#[cfg(feature = "server")]
use std::time::Duration;
/// 退避基准间隔(首次重试前的等待约为 base随后翻倍
#[cfg(feature = "server")]
const BASE_BACKOFF: Duration = Duration::from_millis(200);
/// 最大重试次数(不含首次尝试)。
#[cfg(feature = "server")]
pub const MAX_RETRIES: u32 = 3;
/// 计算第 `attempt` 次重试attempt 从 0 开始)前的等待时长。
///
/// 公式base * 2^attempt再叠加 [0, base) 的 jitter。
/// jitter 由调用方传入的随机比例 [0.0, 1.0) 决定,便于测试时锁定为 0。
#[cfg(feature = "server")]
pub fn backoff_for(attempt: u32, jitter_ratio: f64) -> Duration {
debug_assert!((0.0..=1.0).contains(&jitter_ratio));
let exp = u32::checked_shl(1, attempt).unwrap_or(1 << 30);
let base_ms = BASE_BACKOFF.as_millis() as u64;
let core = base_ms.saturating_mul(exp as u64);
let jitter = (base_ms as f64 * jitter_ratio) as u64;
Duration::from_millis(core.saturating_add(jitter))
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn backoff_grows_exponentially_without_jitter() {
// jitter=0 时序列应严格翻倍200, 400, 800 ms。
assert_eq!(backoff_for(0, 0.0), Duration::from_millis(200));
assert_eq!(backoff_for(1, 0.0), Duration::from_millis(400));
assert_eq!(backoff_for(2, 0.0), Duration::from_millis(800));
}
#[test]
fn backoff_includes_jitter_within_base_range() {
// jitter_ratio=0.5 时在 core 上叠加 base*0.5 = 100ms。
assert_eq!(backoff_for(0, 0.5), Duration::from_millis(300));
assert_eq!(backoff_for(1, 0.5), Duration::from_millis(500));
}
#[test]
fn backoff_clamps_large_attempt() {
// 超大 attempt 不应 panic应靠 saturating 保护返回一个大但有界的值。
let d = backoff_for(40, 0.0);
assert!(d.as_millis() > 0);
}
}

View File

@ -271,6 +271,7 @@ fn main() {
}
// 自定义 API 路由:图片上传(大文件,需要更长超时)
// CSRF 校验置于最外层,先拦截非法来源再做超时/限体。
let upload_route = axum::Router::new()
.route(
"/api/upload",
@ -280,16 +281,23 @@ fn main() {
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(300),
))
.layer(axum::middleware::from_fn(
crate::api::csrf::csrf_middleware,
));
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
let dioxus_app =
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
// 合并 Dioxus + 世代号/缓存头/可选压缩/30s 超时中间件
// 合并 Dioxus + CSRF/世代号/缓存头/可选压缩/30s 超时中间件
// layer 顺序后加的最外层先执行。CSRF 最外层先拦截非法来源。
let mut app_routes = dioxus_app
.layer(axum::middleware::from_fn(ssr_generation_middleware))
.layer(axum::middleware::from_fn(add_cache_control));
.layer(axum::middleware::from_fn(add_cache_control))
.layer(axum::middleware::from_fn(
crate::api::csrf::csrf_middleware,
));
if let Some(layer) = compression_layer_from_env() {
app_routes = app_routes.layer(layer);
}

View File

@ -42,6 +42,8 @@ pub struct User {
pub role: UserRole,
/// 账户创建时间。
pub created_at: DateTime<Utc>,
/// 会话世代号,角色/封禁变更时 +1 使旧 session 失效。
pub session_generation: i32,
}
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
@ -57,6 +59,8 @@ pub struct SessionUser {
pub role: UserRole,
/// 账户创建时间。
pub created_at: DateTime<Utc>,
/// 会话世代号,签发 session 时记录;与 users 表当前值不一致则 session 失效。
pub session_generation: i32,
}
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
@ -83,6 +87,7 @@ impl From<User> for SessionUser {
email: u.email,
role: u.role,
created_at: u.created_at,
session_generation: u.session_generation,
}
}
}
@ -126,6 +131,7 @@ mod tests {
password_hash: "hash".to_string(),
role: UserRole::Admin,
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
session_generation: 0,
}
}

View File

@ -6,7 +6,8 @@
//!
//! 数据获取:
//! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。
//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag)` 获取该标签下的文章列表。
//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag, None, None)`
//! 获取该标签下的全部已发布文章(不分页)。
//! 在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
@ -109,7 +110,7 @@ pub fn TagDetail(tag: String) -> Element {
/// 成功时渲染文章总数与文章卡片。
#[component]
fn TagDetailContent(tag: String) -> Element {
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?;
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone(), None, None))?;
// 将结果映射为 (posts, total) 形式以便渲染。
let posts_data = posts_res.read().as_ref().map(|r| match r {