639 Commits

Author SHA1 Message Date
xfy
0413e539fe docs(env): document IMAGE_DIMENSIONS_CACHE_TTL_SECS 2026-06-22 18:01:31 +08:00
xfy
8ef9ee5621 feat(post-content): lazy-load hi-res images with blur-up fade-in 2026-06-22 18:00:52 +08:00
xfy
1a41f8d7aa feat(image-viewer): render blur-up double-layer structure 2026-06-22 18:00:13 +08:00
xfy
ed91590978 style: add blur-up progressive image loading styles 2026-06-22 17:59:30 +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
6e2e261156 docs(plan): blur-up progressive image loading implementation plan
8 个任务的实现计划,对应 blur-up spec:
- Task 1: image.rs dimensions cache(moka sync + 读 header,webp 走 zenwebp)
- Task 2: sanitizer 扩展放行 data-src/class/style(仅文章)
- Task 3: markdown.rs img 包装成双层 wrapper(正则后处理)
- Task 4: input.css blur-up 样式(模糊占位 + opacity 淡入)
- Task 5: image_viewer.rs 双层结构(SSR 读 dimensions)
- Task 6: post-content.js IntersectionObserver 懒加载 + onload 淡入
- Task 7: .env.example IMAGE_DIMENSIONS_CACHE_TTL_SECS
- Task 8: 全量验证(cargo test/clippy/dx check/css + 手动清单)

每个任务含完整代码、行号、测试、commit。
2026-06-22 17:47:50 +08:00
xfy
987a0d9671 docs(spec): blur-up progressive image loading design
文章图片 blur-up 渐进加载设计文档,经 brainstorming 流程产出:

- 低分辨率占位图(?w=20,SSR 内嵌)+ 高清缩略图双层叠加
- 服务端读图片 header 拿真实尺寸生成 aspect-ratio(零 CLS)
- dimensions 用 moka sync cache 缓存(TTL 可配置,默认 24h)
- 三处图片位置统一(卡片封面/详情封面/正文图)
- Markdown 渲染器把 img 转双层 wrapper,post-content.js 懒加载高清
- sanitizer 扩展放行 data-src/class/style(仅文章,评论不动)
- 外链图不处理,JS 禁用时渐进降级到模糊占位图

webp 尺寸读取风险已排除(zenwebp info() 只读 header)。
验收标准 12 条。不含实现,仅设计。
2026-06-22 17:40:08 +08:00
xfy
876cb55e91 style(editor): smooth blur/grayscale transition on placeholder image
给 img 加 transition: filter 0.2s ease,blur 出现和消失(上传中→成功/
失败)平滑过渡,而非瞬间切换。transition 写在基础 img 规则上,
确保 is-uploading class 移除时过渡也能触发。
2026-06-22 16:27:02 +08:00
xfy
6e04940edc style(editor): blur uploading placeholder image, keep overlay text sharp
上传中占位符图片加高斯模糊,遮罩文字保持清晰。

- .is-uploading img 加 filter: blur(8px)
- container.is-uploading 加 overflow: hidden 防止 blur 边缘渗出
- overlay 文字(spinner/"上传中…")不受影响:overlay 是 img 的兄弟元素
  而非后代,CSS filter 只作用于目标元素本身,不波及兄弟
2026-06-22 16:24:02 +08:00
xfy
75adaa2e1f fix(write): use signal for seen_error_ids to satisfy FnMut constraint
dev server (wasm32 target) 暴露的编译错误:
use_future 的 FnMut 闭包要求可重复调用, 但 seen_error_ids 作为局部
HashSet 被 move 进 async 后无法多次消费 (E0507).

改为 use_signal(HashSet::new): signal 是 Copy, 可被 FnMut 多次 move
进 async, 用 .write()/.read() 访问.

教训: cargo check (默认 target) 不编译 #[cfg(target_arch="wasm32")]
代码, 这类改动必须用 dx check 验证. 之前 Task 13 用 cargo check 漏判了.

验证: dx check (wasm32, 通过) + cargo test (384 passed)
2026-06-22 15:55:10 +08:00
xfy
cee4f3f3ef fix(upload): address code review findings
final code review 后的修复:

1. blob 检查收紧(Important): md.contains("blob:") 误伤合法讨论 blob URL
   的代码块/正文,改为检测 markdown 图片语法 ](blob: 才算占位符泄漏
2. 重试错误原地更新(Minor): seen_error_ids 去重逻辑导致重试后再失败时
   顶部提示停留在旧错误文案;改为已存在 id 时原地更新 message
3. destroy 清理 coordinatorRef(Minor): destroy() 调 setUploadCoordinator(null),
   避免 NodeView 指向已销毁的 coordinator(防御性)
4. 删除 NodeView 未用的 editor 字段(Nit): 死代码清理

验证: cargo test (384 passed) / clippy (本次零警告) / dx check / tiptap build 全过
2026-06-22 15:49:23 +08:00
xfy
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
xfy
d90dd60e89 feat(write): poll window.__tiptap_uploads for upload events and counts 2026-06-22 15:35:17 +08:00
xfy
cb14924de8 feat(write): add upload state signals for placeholder tracking 2026-06-22 15:33:43 +08:00
xfy
8ffb4451b1 style(editor): add upload placeholder NodeView styles (overlay/spinner/error) 2026-06-22 15:32:32 +08:00
xfy
34c8b47436 feat(editor): route slash-command image upload through coordinator
- slash-command.ts: SlashCommandOptions 加 onInsertUploading option
- slash-command.ts: 上传图片命令优先走 onInsertUploading(coordinator 占位符+上传),
  退回 uploadFn 直接上传(无占位符)
- index.ts: SlashCommand.configure 注入 onInsertUploading,
  闭包延迟读取 this.coordinator(在 editor 创建后才实例化)
2026-06-22 15:30:39 +08:00
xfy
5d527778a5 feat(editor): wire UploadCoordinator and custom Image in TiptapEditorInstance
- index.ts: 替换 Image.configure 为 UploadImage(自定义扩展+NodeView)
- index.ts: editor 创建后实例化 UploadCoordinator 并注入 NodeView
- index.ts: FileHandler onPaste/onDrop 改走 coordinator.insertUploading
- index.ts: 暴露 removeUploadByUploadId 供 Rust ×关闭调用
- index.ts: destroy 清理 coordinator
- upload-image.ts: 修正 import 来源(Image 来自 extension-image, mergeAttributes 来自 core)
2026-06-22 15:28:40 +08:00
xfy
57bea05aa3 feat(editor): add custom Image extension with upload-state NodeView 2026-06-22 15:25:25 +08:00
xfy
8ce9c58997 feat(editor): maintain window.__tiptap_uploads for Rust polling 2026-06-22 15:24:37 +08:00
xfy
6506931c1d feat(editor): add runUpload and retryUpload to UploadCoordinator 2026-06-22 15:23:57 +08:00
xfy
e06667f792 feat(editor): add UploadCoordinator skeleton with pending map and node removal 2026-06-22 15:22:42 +08:00
xfy
c92ba6d648 docs(plan): editor image upload placeholder implementation plan
13 个任务的实现计划,对应 upload-placeholder spec:
- Task 1-3: UploadCoordinator(pending map / runUpload+retry / notifyRust)
- Task 4: 自定义 Image 扩展 + NodeView 类(三种态)
- Task 5-6: index.ts 接线 + slash-command 走 coordinator
- Task 7: NodeView 样式(遮罩/spinner/错误卡片)
- Task 8-12: write.rs(signal/轮询/提示/保存拦截/fetch 改造)
- Task 13: 全量验证(cargo test/clippy/dx check + 手动清单)

每个任务含完整代码、行号、构建/测试命令、commit。
2026-06-22 15:19:01 +08:00
xfy
055329107e docs(spec): editor image upload placeholder and failure feedback design
Some checks failed
CI / check (push) Failing after 4m45s
CI / build (push) Has been skipped
文章编辑器图片上传占位符与失败提示的设计文档

经 brainstorming 流程产出的 spec,涵盖:
- 自定义 Image 扩展 + NodeView 承载上传状态(uploading/error/done)
- UploadCoordinator 集中管理三个上传入口,按 upload-id 定位节点更新
- JS→Rust 轮询通道(window.__tiptap_uploads)传递上传事件与计数
- 顶部多条堆叠失败提示 + 编辑器内失败卡片(重试/移除)
- 保存拦截双重防护(counts 检查 + markdown 兜底扫描 blob:)
- write.rs fetch 改造,透传服务端中文错误消息

不含实现,仅设计。验收标准 11 条。
2026-06-22 15:09:38 +08:00
xfy
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
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
6ff37322b2 feat(editor): add image upload to slash command and build tiptap in make dev
斜杠命令图片上传 + Makefile dev 构建修复

1. 斜杠命令接入图片上传管线
   - slash-command.ts: SlashCommand 扩展改为 Extension.create<Options>,
     新增 onImageUpload 选项;原单一"图片"命令拆成两个:
     · "上传图片"(📤): 仅在 onImageUpload 可用时出现,触发原生文件选择器
       (accept 限定 jpeg/png/gif/webp,与 upload 端点 MIME 一致),
       上传成功后 setImage 插入,失败 console.error(与 FileHandler 一致)
     · "图片链接"(🖼): 保留原 window.prompt URL 方式
   - index.ts: SlashCommand.configure({ onImageUpload }) 透传宿主回调,
     复用 write.rs 已注入的 /api/upload 管线,无需 Rust 端改动

2. Makefile: make dev 不再忽略 tiptap 构建
   - 根因: 原 dev target 只起 tailwindcss watch + dx serve,从不构建
     tiptap 编辑器,导致 dev server 始终 serve 上次 make build 残留的
     旧 editor.js(本次新增的"上传图片"命令在 dev 下永远不生效)
   - 新增 build-editor-incremental target (跳过 npm ci,直接 vite build,~1s)
   - dev 现在依赖 build-editor-incremental,启动前先增量构建编辑器

3. Makefile: clean 不再删 public/tiptap
   - public/tiptap 是跨子项目(npm)构建产物,删除后必须重跑 make build-editor
     (含 npm ci) 才能恢复;原 clean 会删它是陷阱(make clean && make dev
     会导致编辑器直接消失)。现在 clean 只清 Rust 产物和缓存
2026-06-22 14:18:05 +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
694e198331 docs(env): expand and correct .env.example comments
依据代码核对修正多处不准确/缺失的注释,纯文档改动,无行为变化。

修正的不准确项:
- RATE_LIMIT_STRICT_* 实际覆盖登录/注册/评论预检/搜索,原注释只写登录注册
- STATEMENT_TIMEOUT_SECS 是 Postgres 服务端取消(非客户端断连),仅经连接池的连接生效
- COMPRESSION_ALGORITHMS 示例值(四项全开)等价于 all,消除注释默认 vs 示例值的歧义

补全的缺失细节:
- RUST_LOG 补细粒度语法示例(info,yggdrasil=debug)
- 限流项统一说明 token bucket 模型(PER_SEC 稳态 / BURST 突发容量)
- MAX_SESSIONS_PER_USER 超限按最旧优先删除(LRU)
- DB_POOL_SIZE 与 Postgres max_connections 的关系
- WEBP_QUALITY/METHOD 越界会 clamp 不报错
- TRUSTED_PROXY_COUNT 设错的双向安全后果
- IMAGE_DISK_CACHE_* 清理任务每小时跑一次
- MIGRATE_STARTUP_TIMEOUT_SECS 500ms 固定间隔轮询
- SSR_CACHE_SECS 文章更新后最长滞后 TTL 秒可见
- APP_BASE_URL 反向代理后 Host 头可被影响的 CSRF 风险

结构上用分隔线分组(Rate Limit / Security / WebP / SSR / Image Cache),
安全注意事项用 ⚠️ 标记。
2026-06-22 13:32:49 +08:00
xfy
2c7319c220 docs(env): translate .env.example comments to Chinese
纯文档翻译,无行为变化。把英文注释全部译为中文,保持原有中文注释
(限流段)和变量名、默认值、文件结构不变。
2026-06-22 13:20:31 +08:00
xfy
a3ed0a2b4e docs(env): document RATE_LIMIT_COMMENT_* and RATE_LIMIT_UNKNOWN_* vars
rate_limit.rs registers four limiters (strict/upload/image/comment) plus
an "unknown" fallback bucket for when the real client IP can't be
determined, but only the strict/upload/image tiers were listed in
.env.example and AGENTS.md. Add the missing comment and unknown tier
tunables so the documented surface matches the code.
2026-06-22 13:16:34 +08:00
xfy
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
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
20e352bf85 Merge branch 'feat/embedded-migrations'
Embed database migrations into the server binary: migrations now run
automatically on startup before the server listens, via a hand-written
runner (~150 LOC) using the existing tokio-postgres/deadpool stack.

- New src/db/migrate.rs: MIGRATIONS constant (include_str!), MigrateError,
  run() with advisory lock + per-migration transactions
- schema_migrations table tracks applied versions
- main.rs drives the async runner via a dedicated transient tokio runtime
- migrate.sh retained as a manual/CI fallback with updated header
- 4 unit tests (sorted, unique, non-empty, files-match-rows)
- Verified end-to-end: cold start, warm restart, rewind, failure path
2026-06-18 16:47:16 +08:00
xfy
91d83f714c docs(migrate.sh): note server auto-migrates on startup; this is a fallback 2026-06-18 16:06:12 +08:00
xfy
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
xfy
283d8b5f4d feat(db): implement migrate::run() with advisory lock and per-migration transactions 2026-06-18 15:59:45 +08:00
xfy
920e26e213 test(db): assert migrations/*.sql files are registered in MIGRATIONS 2026-06-18 15:47:53 +08:00
xfy
d044304969 feat(db): scaffold migrate module with MIGRATIONS constant and error type 2026-06-18 15:41:31 +08:00
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