211 Commits

Author SHA1 Message Date
xfy
edee09596c test(api/markdown): 解耦 wrap_images_with_blur 并脱离文件系统依赖
把 wrap_images_with_blur 拆成两部分:
- wrap_images_with_blur_with(html, dims_fn):纯函数核心,接受 dimensions
  查询闭包,不碰文件系统
- wrap_images_with_blur(html):薄包装,注入真实 get_image_dimensions

测试改用纯函数核心,注入确定性 dimensions(如 Some((800,600)) 或 None),
彻底脱离 uploads/ 真实文件依赖。此前的 aspect-ratio 测试硬编码了
/uploads/2026/06/18/...webp 真实路径——文件不存在则静默走缺省分支,
断言虽能过但实际验证内容被削弱。

新增覆盖(共 8 个 wrap 相关测试):
- --ar 缺省/有值两种路径
- rel_path 去查询的验证(用 RefCell 捕获 Fn 闭包入参)
- alt 保留(双层)/空 alt 不生成属性
- 斜杠分隔的 --ar 格式 + sanitizer 管线不破坏斜杠
2026-07-02 17:39:15 +08:00
xfy
ab05c01f76 test(api/database): 备份恢复补单测 + 修复 backup_path 路径穿越漏洞
backup.rs 是恢复流程的安全关键路径,此前无任何单测。本次:

提取纯函数(便于单测,无行为变更):
- is_valid_backup_filename:文件名白名单校验,替换三处内联 Regex
- parse_backup_mode:从 -- mode: 行提取模式值
- has_valid_signature:首行签名头校验

新增 15 个单测,覆盖:
- 文件名白名单(正常/路径穿越/特殊字符三类边界)
- backup_path 路径穿越纵深防御
- 签名校验(精确/前导空白/非系统文件/空内容)
- 模式解析(pg_dump/sql-fallback/缺失/空值/多次)

修复一个真实的路径穿越漏洞(测试驱动发现):
backup_path("/etc/passwd") 之前返回 /etc/passwd 而非 backups/——
因为 [BACKUP_DIR, filename].collect::<PathBuf>() 在 filename 为绝对
路径时会丢弃 BACKUP_DIR 前缀(PathBuf 语义),随后的 components 检查
在错位的路径上运行而漏判。改为先校验 filename 自身的 components
(只允许 Normal 段),从根上杜绝穿越。

注:第一道白名单 is_valid_backup_filename 已拒绝含 / 的文件名,
故该漏洞实际不可达;但纵深防御必须健壮,本修复使其名副其实。
2026-07-02 17:37:07 +08:00
xfy
533cabb0b5 test(api/database): SQL 控制台护栏补表驱动单测 + 加固
sql_console.rs 是 SQL 控制台的安全关键路径,此前无任何单测。本次:

测试(新增 24 个,覆盖护栏 1+2 全分支):
- check_guards: DROP SCHEMA/CREATE DATABASE 绝禁、DROP/TRUNCATE/ALTER
  需确认、UPDATE/DELETE 无 WHERE 绝禁(且 confirm 不能救)、多语句短路
- is_absolutely_forbidden: 大小写不敏感、误拦检查、报错名
- statement_type_name / is_read_only: AST 变体映射
- 两条安全不变量测试(见下)

加固(测试驱动发现的两个真实缺口):
1. 字符串预检 contains() 改为 token 序列匹配 is_absolutely_forbidden()——
   防多空格/制表符/换行绕过 DROP/CREATE DATABASE。
2. check_guards 补 Statement::CreateDatabase 绝禁分支——此前 sqlparser
   能解析 CREATE DATABASE(可绕过字符串预检进 AST),但 AST 层无对应分支,
   字符串预检是其唯一防线。现在双重拦截。

并锁定两条安全不变量,防止 sqlparser 升级时悄悄回退:
- DROP DATABASE 不可解析 → 字符串预检是唯一防线
- CREATE DATABASE 可解析 → 双重防线都生效
2026-07-02 17:33:19 +08:00
xfy
291189fb77 fix(sanitizer): 折叠嵌套 if 修复 clippy::collapsible_if
865871f 引入的 checkbox type 白名单逻辑用了嵌套 if,触发 rust 1.96
的 clippy::collapsible_if,导致 make clippy 直接失败。按 clippy 建议
合并为单个 if 链,行为不变。
2026-07-02 16:36:05 +08:00
xfy
dcdd72a8c2 refactor(utils): 拆分 comment_storage 的时间/HTML 工具函数
消除 server 端对 hooks 模块的跨层引用:

- escape_html(+ 两份重复实现:comment_storage 用 &#39;、helpers 用 &#x27;)
  统一到 utils/html.rs,单引号采用 HTML5 标准 &#x27;。
- now_millis / relative_label_from_millis / format_relative_time_iso 及其
  单元测试迁到 utils/time.rs,与既有 sleep_ms 同构(wasm32/非 wasm 双版本)。

调用方迁移:
- api/comments/helpers.rs:删掉本地 escape_html,format_relative_time 改引
  utils::time;其 escape_html 测试随之删除(utils/html.rs 已覆盖)。
- api/comments/create.rs:两处 helpers::escape_html 改 utils::html。
- api/markdown.rs:escape_heading_text 改 utils::html。
- components/comments/pending_item.rs:format_relative_time_iso 改从
  utils::time 引入;render_pending_content 留在 comment_storage(语义紧贴
  待审核评论)。
- hooks/comment_storage.rs:is_expired 的 now_millis 改引 utils::time。

comment_storage.rs 现在只剩结构体与 localStorage 读写,文件名即将自洽。
2026-07-02 16:15:23 +08:00
xfy
865871f45c fix(markdown): 修复任务列表 checkbox 在前台被 sanitizer 剥离
编辑器端已挂 TaskList/TaskItem,但前台渲染走 pulldown-cmark,其输出的
<input type="checkbox"> 不在 sanitizer 白名单内,被 remove_and_keep_content
剥离,导致任务列表在前台只剩裸 <li> 文本。

- sanitizer: 白名单放开 input,但强制 type="checkbox" 属性值校验
  - attrs_to_remove 增加 input.type 值校验,非 checkbox 一律删除属性
  - element_handler 末尾兜底:缺 type 或非 checkbox 的 input 整标签移除
    (void 元素无内容,remove() 不丢正文,封堵 type=image/text 等 XSS 滥用)
- 仅 clean_html 放开,clean_comment_html 保持不放开(评论无需任务列表)
- input.css 补 .md-content 任务列表样式(pulldown-cmark 裸 li>input 结构,
  用 :has() 识别,不支持 :has() 的浏览器降级为普通列表仍可读)
- 新增 sanitizer 5 个测试(白名单/XSS 边界)+ markdown 1 个端到端测试
2026-07-02 13:22:37 +08:00
xfy
5426458f4a fix(docs): resolve rustdoc broken links and dead_code warnings
Some checks failed
CI / check (push) Failing after 5m18s
CI / build (push) Has been skipped
doc 构建报 8 个 broken_intra_doc_links + dx 构建报 7 个 dead_code 警告。
两者根源相同:被引用的常量/类型/模块要么是 server-only(消费方都在
#[cfg(feature="server")] 块里),要么是跨 cfg 引用(host doc 构建看不到
wasm32-only 类型)。

dead_code:给 sql_console 的 MAX_ROWS/ABSOLUTELY_FORBIDDEN/GuardResult 与
backup 的 BACKUP_DIR/FILENAME_RE/BACKUP_SIGNATURE 加 #[cfg(feature="server")]
gate——这些都是安全护栏常量,消费方全在 server fn 体里,WASM 构建剥掉 fn 体后
常量即成死代码。sysinfo_sampler 的 WASM 桩 read_snapshot 加 allow(dead_code)
(与 codemirror_bridge 既有处理一致)。

rustdoc 链接:跨模块引用改全路径(crate::xxx),跨 cfg 引用(EditorHandle 在
wasm32 mod 内、host doc 构建不可见)转义为纯文本。

验证:cargo doc(0 warning)+ cargo check(default)+ wasm32 check 全部干净。
2026-06-30 17:42:52 +08:00
xfy
0b6a076d78 feat(database): use real COUNT(*) for small tables, fall back to estimate for large
row_estimate came from pg_class.reltuples, which is -1 for tables that
have never been ANALYZEd (the common case on a fresh/small DB), so the
admin table list showed -1 for every table — meaningless.

Replace row_estimate with row_count + row_count_estimated:
- total_size < 100MB: SELECT count(*) (real value, cost negligible on
  small tables; identifier quoted via PG escaping to stay injection-safe)
- total_size >= 100MB: fall back to reltuples estimate, flagged so the UI
  can prefix with ~

Frontend now shows real counts (1/2/14...) for small tables and ~N for
large ones; header copy updated to reflect the mixed source.
2026-06-30 13:57:37 +08:00
xfy
36168f26d7 fix(database): cast extract(epoch)::double precision in active-connections query
extract(epoch FROM now() - query_start) returns numeric (decimal) in
PostgreSQL, but the result was read as f64 via r.get(4). tokio-postgres
has no FromSql<f64> impl for the numeric type, so get_db_status panicked
at runtime with 'error deserializing column 4' (HTTP 500).

Cast to ::double precision (float8) to match query_duration_secs: Option<f64>.

Audited the remaining column mappings in status.rs to rule out further
type mismatches — all sound: reltuples::bigint->i64, native bigint size
columns->i64, last_vacuum/last_analyze timestamptz->DateTime<Utc>
(chrono feature enabled), schema_migrations.version TEXT->String,
count(*)::int->i32 (fixed previously).
2026-06-30 13:51:48 +08:00
xfy
116286f80f fix(database): cast count(*)::int in get_db_status to match i32 field
count(*) returns bigint (int8) in PostgreSQL, but the result was read as
i32 via conn_row.get(0). tokio-postgres's FromSql<i32> rejects an int8
column, so get_db_status panicked at runtime with
'error deserializing column 0' (HTTP 500).

Cast count(*)::int to int4, matching the adjacent setting::int column
and the total_connections/max_connections i32 struct fields. No other
queries in the file are affected: reltuples is already ::bigint -> i64,
size columns are native bigint -> i64, and system_status.rs reads its
count(*) as i64 correctly.
2026-06-30 13:47:24 +08:00
xfy
ecf3c73715 fix(database): make src/api/database compile under WASM (web-only) build
The /admin/system feature was never verified against the WASM frontend
target (dx serve builds src/main.rs with --features web and no server).
The entire src/api/database/ tree unconditionally pulled in server-only
crates (axum, sqlparser, dashmap, tokio, tokio_postgres, futures, regex)
and server-gated helpers (get_current_admin_user, parse_session_token,
get_user_by_token), so the frontend build failed with 43 errors.

Root cause + fixes (mirror the established settings.rs pattern):

- Gate server-only  imports behind #[cfg(feature = server)] in
  status/system_status/sql_console/schema/tasks/backup. The #[server] macro
  strips call sites but NOT use statements, so unresolved imports remained.
- Ungate  in main.rs: SystemSnapshot is a shared serde
  type referenced by ServerStatus.host on both targets; only the sampler
  task/SNAPSHOT static are internally server-gated.
- Gate  (pure Axum handler, zero WASM consumers) and the
  download_backup Axum handler in backup.rs.
- Move restore_backup's validation preamble (regex/backup_path/fs) inside
  the #[cfg(feature = server)] block; gate std::path/chrono::Utc imports.
- Add [[bin]] required-features=[server] for generate_highlight_css so the
  syntect-dependent build tool is skipped in web-only builds.

Genuine WASM bugs surfaced once the server bodies stopped masking them:

- system.rs: Closure import path (dioxus::prelude::wasm_bindgen ->
  wasm_bindgen, matching write.rs); setTimeout expects i32 not u32;
  editor_handle() -> editor_handle.read() (Signal is not Fn in 0.7).
- codemirror_bridge.rs: set_schema extern took &SqlSchema but SqlSchema is
  a serde type with no IntoWasmAbi. Changed both set_schema signatures to
  &JsValue; call site now serializes via serde-wasm-bindgen::to_value.
- system.rs: signals .set() inside spawn/use_future/onclick closures need
   bindings (incl. the *_f rebinding copies); added cfg_attr
  allow(unused_mut) on the 4 tab components, matching write.rs convention.

Verified: cargo check (default/server) clean; cargo check --features web
--target wasm32-unknown-unknown clean (0 errors); dx check clean; 411
cargo tests pass.
2026-06-30 13:41:23 +08:00
xfy
37d7ec49eb fix(database): harden SQL console guards + review fixes
最终 code review 发现的安全/正确性修复:

- C1 (critical): execute_one 改用 stmt.to_string() 重序列化的单条语句 SQL
  而非原始整段 SQL,保证执行的语句与护栏检查的 AST 一致;杜绝 allow_multi
  时整段 SQL 被重复执行、读写分类与实际执行解耦。
- C2 (critical): check_guards 在 AST 层结构性禁止 DROP SCHEMA(ObjectType
  匹配),不再仅靠字符串预检——防 SQL 注释/空白绕过。
- I2: 备份/恢复在 DATABASE_URL 为空时提前失败任务,而非传空串给 pg_dump/psql。
- I4: 回退备份进度计算用 u32 避免 >255 表时截断/溢出。
- I5: SQL 导出用真实表名(table 模式)而非硬编码 "export"。
- M7: 移除 SqlResult 中未使用的 total_estimate 字段。
2026-06-29 19:24:06 +08:00
xfy
6e94db0c2b feat(admin): add backup/restore tab (dual-mode + task progress polling)
新增 dashmap 依赖。create_backup 探测 pg_dump 优先(含 schema,签名头前置)、
不可用回退纯 SQL(COPY TO STDOUT 逐表,按表精确进度)。restore_backup 校验
签名头(仅本系统备份)+ 路径穿越防护 + 二次确认,探测 psql 执行。
list_backups/delete_backup 管理备份,GET /api/database/backups/{name} 下载
(admin 鉴权 + 白名单)。tasks.rs DashMap 进度表(1h 惰性 GC),前端每 1.5s
轮询 get_task_progress 显示进度条/stage,完成后刷新列表。
backups/ gitignored 不直接暴露。AGENTS.md 同步新增 CodeMirror 子项目与
数据库管理章节。
2026-06-29 19:16:11 +08:00
xfy
5c4b2a53d1 feat(admin): add data export tab (Axum streaming, SQL/CSV)
新增 GET /api/database/export Axum 流式路由(镜像 upload.rs 的 cookie→admin
鉴权),支持按表/按查询导出。CSV 用 COPY ... TO STDOUT WITH CSV 流式
(大表不 OOM),SQL 逐行拼 INSERT(含类型化转义)。表名白名单 + 查询
AST 只读强制。前端 ExportTab 触发 window.open 下载。
2026-06-29 19:01:16 +08:00
xfy
a49f47c8a6 feat(admin): add SQL console tab (read-write + 4 guards + sqlparser)
新增 sqlparser 依赖(optional+server),AppError 加 BadRequest 变体(护栏
返回动态用户可见消息)。execute_sql server function 全读写执行,4 道护栏:
(1) sqlparser AST 高危语句闸门——DROP DATABASE/SCHEMA 字符串预检绝禁、
DROP/TRUNCATE/ALTER 需勾选「我了解后果」;(2) 无 WHERE 的 UPDATE/DELETE 拒绝;
(3) 复用 STATEMENT_TIMEOUT_SECS 超时上限;(4) 前端写操作二次确认。
默认禁多语句,结果 500 行截断。get_db_schema 拉表/列供 CodeMirror 补全。

前端 SqlConsoleTab:CodeMirror 编辑器(Vim + Catppuccin 主题跟随站点 +
实时 schema 补全)+ 选项 toggles + 结果表格 + EXPLAIN 输出。
2026-06-29 18:56:15 +08:00
xfy
c4c490b881 feat(admin): add server status tab (sysinfo host metrics + cache hit rate)
新增 sysinfo 依赖(optional+server),sysinfo_sampler 后台采样任务
(SYSINFO_SAMPLE_SECS 可配,默认 0.5s)周期刷新 CPU/内存/磁盘/load 到 RwLock
快照,get_server_status 只读快照零成本。cache.rs 给 9 个缓存加 AtomicU64
hit/miss 计数 + cache_stats() 聚合命中率。

前端 ServerStatusTab:应用内指标(运行时间/连接池/会话/CPU/内存/磁盘/load)+
缓存命中率表 + 刷新按钮 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。
2026-06-29 18:45:02 +08:00
xfy
7553dcf405 feat(admin): add database status tab (tables/connections/migration version)
get_db_status server function 查 pg_catalog 聚合:数据库总大小/连接数/
表清单(行数估算+大小+死元组)/索引占用 Top10/活跃连接(过滤自身)/迁移版本。
前端 DbStatusTab:概览卡片 + 表/索引/连接表格 + 刷新按钮 + 自动刷新开关
(1s/2s/5s/30s/手动,默认手动;wasm 端用 web_sys setTimeout 轮询,无新依赖)。
2026-06-29 18:36:08 +08:00
xfy
373498870a style: apply cargo fmt to workspace
Some checks failed
CI / check (push) Failing after 5m35s
CI / build (push) Has been skipped
2026-06-29 13:47:40 +08:00
xfy
74f8c212f6 feat(csrf): warn at startup when APP_BASE_URL is unset
APP_BASE_URL 未设置时 trusted_origin 回退到请求 Host 头推导本站 origin,
反向代理后若 Host 头可被客户端影响存在 CSRF 绕过风险。.env.example 虽然
警告了,但默认值就是空——'cp .env.example .env 后忘改' 是最常见的部署失误。

加启动时一次性 WARN(与 image.rs 的启动告警同范式):
- warn_if_app_base_url_unset() 在 main.rs 启动序列调用,与 validate_database_url
  等配置告警归在一处
- 抽出纯函数 app_base_url_is_set() 承载判断逻辑,便于测试,打日志副作用与之解耦
- 5 个单测覆盖 unset/empty/whitespace/set/trim 边界

每请求路径的 trusted_origin 保持纯函数不变,不引入 dedup 状态或刷屏风险。
2026-06-29 11:02:58 +08:00
xfy
96da2f27d5 docs: 补齐 tiptap_bridge 文档注释并新增 make doc 目标
- tiptap_bridge.rs: 为 wasm-bindgen 桥接层全部 19 处 pub 项补文档
  (JS 类型映射、getter/setter、EditorHandle::new、pub use 重导出)
- markdown.rs: 用反引号包裹注释内字面量 <span>,消除 rustdoc 警告
- Makefile: 新增 doc / doc-open,纯 binary crate 需 --document-private-items
  才能让内部模块进文档
2026-06-25 18:26:56 +08:00
xfy
c836e3e1df feat(api): 新增 healthz/readyz 健康检查端点
Some checks failed
CI / check (push) Failing after 7m29s
CI / build (push) Has been skipped
新增两个无中间件探针端点,供 Docker HEALTHCHECK 与反向代理/负载
均衡使用:

GET /healthz — liveness 存活探针,进程在跑即 200,不查 DB。
GET /readyz  — readiness 就绪探针,SELECT 1 检测 DB 连通性(2s
超时),不可达返回 503,附带连接池 size/available/max_size/waiting
指标。

路由挂载在 static_routes(无 CSRF/超时/缓存中间件),避免被
add_cache_control 误加 max-age 缓存头导致探针误判。readyz 直接用
DB_POOL.get() 不走 get_conn 的退避重试,保证探针快速失败。

dx check + clippy + 405 tests 全通过。
2026-06-25 16:45:09 +08:00
xfy
ff2694c5a3 feat(image): make size/pixel limits configurable, double defaults
Some checks failed
CI / check (push) Failing after 6m23s
CI / build (push) Has been skipped
将 MAX_IMAGE_DIMENSION / MAX_IMAGE_PIXELS 从编译期 const 改为运行时
LazyLock(启动读环境变量),默认值 x2。

- 默认值:单边 4096→8192,总像素 25M→50M
- clamp 策略:只设下限防危险小值(DIMENSION≥512,PIXELS≥1M),
  超下限 clamp + warn;无上限,完全信任运维
- 内存影响:默认 50M 像素对应 ~200MB/图解码缓冲(max_alloc)
- 模式复用 WEBP_CONFIG:env→parse→(val,clamped)→clamp→warn→info
- 所有引用点解引用(*MAX_...),含 upload.rs/webp.rs 跨文件
- check_upload_dimensions 超限文案改为动态读取上限,不再硬编码数值
- 测试改用 *MAX_IMAGE_DIMENSION+1,数值无关

.env.example 与 AGENTS.md 同步更新环境变量说明。
2026-06-24 13:54:36 +08:00
xfy
0777fd469a fix(upload): unify image size limit across all formats
上传入口此前对超大图片行为不一致:JPEG/PNG 因 decode limits 失败后
静默存原图,WebP 报"损坏",GIF 完全不查。统一为 header 阶段硬拒绝。

- image.rs: 新增 check_upload_dimensions,按 MIME 只读 header 拿尺寸
  (WebP 走 zenwebp header,JPEG/PNG/GIF 走 image into_dimensions),
  超 MAX_IMAGE_DIMENSION/MAX_IMAGE_PIXELS 返回友好提示
- upload.rs: magic bytes 校验后插入统一尺寸校验,三种格式同路径
- upload.rs: JPEG/PNG 转码 fallback 的 Err(_) 改为 Err(e) 带原因日志
  (超限已在 header 阶段拦截,此处仅剩真损坏兜底)

读取侧 serve_image 不动;webp::decode 内的像素校验保留(读取侧仍需)。
2026-06-24 13:25:23 +08:00
xfy
9c2b0b1a66 test(markdown): remove diagnostic marker, keep slash verification test
移除 data-pipeline-v2 诊断标记(已完成使命:确认新二进制含新代码)。
保留 full_pipeline_wrap_then_clean_preserves_slash 测试,它验证完整
渲染管线(wrap → clean_html)的斜杠格式不被 sanitizer 破坏。

根因记录: 文章正文 HTML 存在 posts.content_html 字段,创建/更新时
渲染一次存入 DB,展示时直接读 DB 不重新渲染。blur-up 的 markdown
改动只对新渲染生效,存量文章需重新保存或 rebuild_content_html 触发重渲染。
2026-06-23 09:58:53 +08:00
xfy
d194f17013 test(markdown): verify aspect-ratio uses slash separator with real dimensions 2026-06-22 18:24:29 +08:00
xfy
6e98c2abfa fix(blur-up): use slash separator for aspect-ratio (--ar W / H)
根因: CSS aspect-ratio 合法语法是 "width / height"(斜杠分隔),
但 --ar 生成时用了冒号 ":"(--ar:2974:2066)。冒号在 CSS 里是声明分隔符,
2974:2066 被判非法值, aspect-ratio 退化为 auto, 容器高度为 0, 图片不可见.

控制台数据证实: aspectRatio:"auto", clientHeight:0, arVar:"2974:2066"

修复: markdown.rs(正文图) 和 image_viewer.rs(封面) 两处把
{}:{} 改成 {} / {}. CSS 规则 aspect-ratio: var(--ar) 不变,
var 展开后变成合法的 "2974 / 2066".
2026-06-22 18:11:04 +08:00
xfy
0fc56adfcd feat(markdown): wrap uploads images with blur-up double-layer structure 2026-06-22 17:58:37 +08:00
xfy
4690b64a99 feat(sanitizer): allow data-src/class/style on img for blur-up 2026-06-22 17:57:05 +08:00
xfy
45d14e2ad9 feat(image): add image dimensions cache with header-only reading 2026-06-22 17:55:16 +08:00
xfy
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
xfy
10ef52bede fix(comment): harden comment form with server honeypot, a11y labels, and reply layout
Some checks failed
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
xfy
67212a52b3 fix(upload): make ConnectInfo optional to prevent 500 in release builds
Some checks failed
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
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
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