a10f79d47e
feat(write): upload failure notices, save blocking, and server error passthrough
...
- 顶部上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿)
for err in upload_errors().clone() 取 owned 值,onclick 捕获 owned id
- 保存拦截双重防护:
· counts 检查(uploading/error > 0 阻止保存并提示张数)
· markdown 兜底扫描 blob:/data-upload-state(轮询窗口期漏判防护)
- onImageUpload fetch 改造:非 2xx 时读取服务端 error 字段,
透传中文错误(如"文件超过大小限制")而非 'Upload failed: 413'
2026-06-22 15:38:53 +08:00
d90dd60e89
feat(write): poll window.__tiptap_uploads for upload events and counts
2026-06-22 15:35:17 +08:00
cb14924de8
feat(write): add upload state signals for placeholder tracking
2026-06-22 15:33:43 +08:00
3df677e181
feat(comment): show real relative time on pending comments
...
待审核评论显示真实相对时间,而非永久"刚刚"
pending_item.rs 改用 format_relative_time_iso 基于创建时间动态计算
相对文本(承接上一个 relative time 重构 commit),并加 title 悬浮提示
显示原始 ISO 时间。修复待审核评论一直显示"刚刚"的问题。
2026-06-22 14:19:05 +08:00
d3082d5c64
refactor(comment): share relative-time bucketing between server and client
...
提取相对时间分档逻辑,服务端与前端共享同一份实现
- comment_storage.rs 新增 relative_label_from_millis / format_relative_time_iso:
按 delta 毫秒分档(刚刚/N 分钟前/N 小时前/N 天前/超过 30 天显示日期),
返回 (相对文本, 绝对日期 YYYY-MM-DD);解析失败兜底为空串/刚刚,不 panic
- helpers.rs 的 format_relative_time 重构为调用共享函数,消除服务端预渲染
与前端实时计算两份分档逻辑的口径漂移风险
- 补充 8 个单元测试覆盖各分档边界与异常输入
测试: cargo test relative (18 passed)
2026-06-22 14:18:36 +08:00
10ef52bede
fix(comment): harden comment form with server honeypot, a11y labels, and reply layout
...
CI / check (push) Failing after 5m20s
CI / build (push) Has been skipped
四项评论区输入框修复:
1. 蜜罐服务端二次校验
- helpers.rs 新增 validate_comment_honeypot 校验器 + 单元测试
- create_comment 加 honeypot 参数,在限流后做服务端校验
- 即便机器人禁用 JS 绕过前端拦截,服务端仍返回 spam_detected 错误
- 与 rate limit 共同构成纵深防御
2. label/input 关联 (可访问性)
- 四个字段补 id + r#for 属性,用 id_suffix(顶层 root/回复用 parent_id)保证页面唯一
- textarea 原本无 label,补上"内容 *"label
3. 提交按钮右对齐 + 宽度自适应
- 新常量 COMMENT_SUBMIT_CLASS 去掉 w-full、px-4 改为 px-6
- 外层包 flex justify-end,按钮跟随文字宽度
- login/register 的全宽按钮保持不变(模态卡片场景)
4. 深层回复表单回到内容区左边缘
- CommentForm 新增 parent_indent prop,回复时用负 margin 抵消父评论缩进
- 避免 depth 越深表单被越挤越右
验证: cargo test (376 passed) / cargo clippy --all-targets (无警告) / dx check (无问题)
2026-06-22 13:45:28 +08:00
b24cfdcabc
fix(startup): replace migration panic with friendly exit + configurable retry window
...
DB migration failure at startup panicked via .expect() (main.rs:223),
dumping a raw backtrace for what is usually just "PostgreSQL isn't running
yet". Worse, the 1.6s runtime retry budget was reused at startup, so it
almost always failed against a cold/slow DB (docker-compose without
healthchecks, local Postgres not started, etc.).
Changes:
- pool: extract build_pg_config() (returns Result, no panic); add
validate_database_url() so URL-format / DB_POOL_SIZE errors surface as
friendly exit(1) instead of hitting the LazyLock's unreachable .expect()
- pool: add get_conn_for_startup() — a startup-only retry loop with a
configurable total-duration window (MIGRATE_STARTUP_TIMEOUT_SECS,
default 30s, 500ms interval), separate from the runtime get_conn()
anti-avalanche fast retry (untouched)
- migrate: split run() into run_on_conn(&mut conn) so callers control the
connection-acquisition strategy; main.rs pairs this with the startup
retry. Advisory-lock release comment updated: exit(1) terminates the
process just like panic, so the session-level lock is still freed
- main: startup fatal errors (bad URL, DB unreachable, migration failed)
now each emit tracing::error! + eprintln! ERROR + targeted HINT, then
exit(1) — no panic, no backtrace nudge
- .env.example / AGENTS.md: document MIGRATE_STARTUP_TIMEOUT_SECS
No runtime behavior change: get_conn() and its ~40 call sites are
unchanged. Advisory-lock safety preserved (documented in migrate.rs).
2026-06-22 11:24:13 +08:00
67212a52b3
fix(upload): make ConnectInfo optional to prevent 500 in release builds
...
CI / check (push) Failing after 26m31s
CI / build (push) Has been skipped
upload_image declared ConnectInfo<SocketAddr> as a required Axum extractor,
but dioxus::server::serve() owns the listener and cannot call
into_make_service_with_connect_info::<SocketAddr>(), so the request extension
is absent on manually-merged routes. The extractor failed and Axum returned
500. dev (dx serve) passed by luck; release builds failed consistently.
Match serve_image's graceful-degradation pattern: take
Option<Extension<ConnectInfo<SocketAddr>>>, fall back to the 'unknown'
rate-limit bucket when missing. Production should deploy behind a reverse
proxy with TRUSTED_PROXY_COUNT so rate limiting keys on the real client IP.
Also correct the misleading comment in main.rs: axum 0.8 does have
ConnectInfoLayer/into_make_service_with_connect_info; the real blocker is
that Dioxus owns the listener.
2026-06-18 17:07:07 +08:00
356f4354dc
feat(main): run database migrations on server startup before listening
...
main() is sync, so the async migrate::run() is driven by a dedicated
multi-thread tokio runtime that is built, blocked-on, and dropped before
dioxus::server::serve() starts its own runtime. This keeps the two
runtimes from overlapping.
2026-06-18 16:01:45 +08:00
283d8b5f4d
feat(db): implement migrate::run() with advisory lock and per-migration transactions
2026-06-18 15:59:45 +08:00
920e26e213
test(db): assert migrations/*.sql files are registered in MIGRATIONS
2026-06-18 15:47:53 +08:00
d044304969
feat(db): scaffold migrate module with MIGRATIONS constant and error type
2026-06-18 15:41:31 +08:00
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
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
57488f5c40
style(csrf): replace redundant closures with function references (clippy)
2026-06-18 13:45:32 +08:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
306da3cf83
Merge caching and SSR invalidation improvements
CI / check (push) Failing after 24m9s
CI / build (push) Has been skipped
2026-06-18 10:43:40 +08:00
7f372446da
refactor(ssr): restrict X-SSR-Generation header to GET requests and add serial tests
2026-06-18 10:30:12 +08:00
3ee39d910c
refactor(ssr): remove unused per-slug/per-tag generation counters
2026-06-18 10:30:07 +08:00
4c695b4fc3
refactor(tasks): use named constants for MB and hours in cleanup
2026-06-18 10:11:07 +08:00
12355b7859
fix(tasks): skip symlinks during image disk cache cleanup
2026-06-18 10:10:51 +08:00
a71da7473d
perf(image): remove redundant Vec clone before spawn_blocking
2026-06-18 10:10:44 +08:00
0b107c3f2e
feat(tasks): add periodic image disk cache cleanup
2026-06-18 10:03:38 +08:00
36554af5f5
perf(image): store cached image data as Bytes to avoid Vec cloning
2026-06-18 10:03:35 +08:00
411e565465
docs(cache,search): update module and function comments
2026-06-18 09:55:45 +08:00
24bc6f44a0
fix(tasks): invalidate session cache after cleaning expired sessions
2026-06-18 09:55:04 +08:00
518b4e5d64
refactor(auth): store SessionUser instead of full User in session cache
2026-06-18 09:55:00 +08:00