Skip to content

feat: complete submission + admin review + OAuth + CI/CD - #2

Merged
xzdmycbx merged 80 commits into
TransCircle:mainfrom
lxgy1024:main
Jun 18, 2026
Merged

xzdmycbx merged 80 commits into
TransCircle:mainfrom
lxgy1024:main

Conversation

@lxgy1024

@lxgy1024 lxgy1024 commented May 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Complete implementation of TransCircle submit system covering all 6 requirements from TODO.md.

What's included

  • Submit form — Markdown editor, category selector, author attribution (real/pen name/anonymous)
  • Serverless API — CF Pages Functions with CORS, rate limiting, honeypot anti-abuse
  • OAuth login — GitHub OAuth + X OAuth 2.0 (PKCE) with encrypted session cookies; anonymous submission always available
  • Admin dashboard — GitHub org membership verification + temp token fallback; approve/reject/request changes
  • Auto-build trigger — On approval, writes approved submissions to story.transcircle.org repo, triggering CF Pages rebuild
  • CI/CD — GitHub Actions workflow for automatic CF Pages deploy on push to main

Post-merge setup

# 1. Create D1 database
npx wrangler d1 create transcircle-submissions
# → replace PLACEHOLDER_D1_ID in wrangler.jsonc

# 2. Apply migration
npx wrangler d1 execute transcircle-submissions --file=./migrations/0001_submissions.sql --remote

# 3. Set required secrets (CF Pages Dashboard)
#    TEMP_ADMIN_TOKEN   — temp admin access before OAuth is configured
#    SESSION_SECRET     — openssl rand -hex 32

# 4. Optional secrets (for full functionality)
#    GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
#    X_CLIENT_ID / X_CLIENT_SECRET
#    STORY_REPO_TOKEN  — GitHub PAT for auto-build trigger

Architecture

User → submit.transcircle.org (React SPA + CF Pages Functions)
         ├── POST /api/submit → D1 (submissions)
         ├── GET/PATCH /api/admin/* → D1 + GitHub API
         └── On approval → pushes data to story.transcircle.org repo

Test plan

  • Anonymous submission at /submit → receives TC-xxx ID
  • Admin login at /admin → sees pending list → reviews submission
  • Rate limiting: >10 POSTs/hour → 429
  • Honeypot: bot fills hidden field → fake success, no DB insert
  • Approved submission triggers story repo update

@lxgy1024
lxgy1024 force-pushed the main branch 2 times, most recently from a15210e to 494827d Compare May 29, 2026 12:37
- Submit form with Markdown editor, category, author type, anti-abuse (honeypot, rate limit)
- CF Pages Functions API: submit, admin list/detail/review
- GitHub OAuth + X OAuth 2.0 (PKCE) login with encrypted session cookies
- Admin dashboard with org-membership verification + temp token fallback
- Auto-trigger story site rebuild on approval
- GitHub Actions CI/CD for CF Pages deploy
@lxgy1024

Copy link
Copy Markdown
Contributor Author

记得配好 CLOUDFLARE_API_TOKEN 和 CLOUDFLARE_ACCOUNT_ID 啊QAQ

@xzdmycbx
xzdmycbx self-requested a review May 30, 2026 12:33

@xzdmycbx xzdmycbx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

代码审查问题报告

审查范围:提交 62fd037..471f76b(投稿 + 管理员审核 + OAuth + CI/CD)
总体评价:代码结构清晰、命名规范、注释到位,工程素养在水准之上。但本次提交存在多个被「可选链 / 静默失败」掩盖的功能性缺陷,以及一个真实可利用的存储型 XSS。


优先级总览

编号 问题 级别
A1 自建 /api 后端与 api.md 契约不兼容 🔴 必修(架构决策)
B1 审核通过后 story 站点不会自动重建 🔴 必修
B2 退出登录无效,刷新后恢复登录 🔴 必修
B3 投稿未关联账号,与 UI 承诺不符 🔴 必修
B4 session 加密密钥派生缺陷(示例值即失效) 🔴 必修
B5 OAuth state 生成但从不校验(CSRF) 🔴 必修
S1 公开 story 站点的作者名存储型 XSS 🔴 必修
C1 functions/ 目录完全绕过类型检查 🟠 高
C2 内存限流不可靠 🟠 高(生产)
S2 临时管理员 token 长期存于 localStorage 🟠 高
C3 运行时建表与 migrations 双轨、CI 不跑 migration 🟡 中
C4 审核接口无乐观锁、404 文案名实不符 🟡 中
C5 CORS 含 localhost、缺 Vary: Origin 🟡 中
C6 generateId 同毫秒可能碰撞 🟡 中
C7 字符长度按 UTF-16 计,与契约不符 🟡 中
E2 CI 在 PR 上执行部署、无并发控制 🟡 中
D1 表单标签未与控件关联 🔵 低
D2 列表项键盘不可达 🔵 低
D3 错误提示无 live region 🔵 低
E1 移动端布局需实机验证 🔵 低

🔴 必修

A1 · 自建 /api 后端与 api.md 契约不兼容

作为对接 api.md 的前端,functions/ 却实现了一套不兼容的替代后端:

维度 api.md 契约 当前实现
路径 /v1/*(api.md:39) /api/*(SubmitForm.tsx:82、AuthContext.tsx:25)
认证 JWT + Refresh Token(api.md:53) 加密 Cookie tc_session(_session.ts:78-79)
响应体 {data, requestId} / {error:{code,message,details}}(api.md:134+) 裸 {id,status} / {error:"中文"}(submit.ts:109/81)
分页 游标分页(api.md:108) limit/offset(admin/submissions.ts:13-14)
资源 ID ULID TC-<ts>-<rand>(submit.ts:23-25)
时间 Unix 毫秒 ISO 字符串(submit.ts:88)

这是需要先拍板的根因问题:要么前端改为对接 api.md 真后端(移除/降级 functions/),要么明确本服务为独立简化系统并更新文档。在此决策确定前,下方各项的修复都只是给一套本不该长期存在的实现打补丁。

建议方案:明确架构方向。若保留独立后端,应在 README/文档中声明「本服务不遵循 api.md」;若需对接真后端,则下线 functions/ 自建实现,前端改调 /v1/*。


B1 · 审核通过后 story 站点不会自动重建

functions/api/admin/review.ts:21 仅解构 { request, env },:76 调用 env.ctx?.waitUntil(triggerRebuild(env))。Cloudflare Pages Functions 的 waitUntil 是 EventContext 的顶层属性(与 env 并列),env.ctx 恒为 undefined,可选链把整个 triggerRebuild(env) 调用一起跳过——审核「通过」后的自动发布链路实际断裂(手动触发仍可重建)。

建议方案:从上下文顶层解构 waitUntil 使用:

export const onRequestPatch: PagesFunction<Env> = async ({ request, env, waitUntil }) => {
  // ...
  if (body.action === 'approve') waitUntil(triggerRebuild(env))
}

B2 · 退出登录无效

src/context/AuthContext.tsx:44-46 的 logout 只执行 setUser(null);没有任何后端 logout 路由;tc_session 是 HttpOnly Cookie,前端 JS 无法清除;functions/api/auth/me.ts:10-24 会继续凭残留 Cookie 返回用户。结果:刷新页面即恢复登录。_session.ts:104 已实现 clearSessionCookie,但从未被引用。

建议方案:新增 functions/api/auth/logout.ts,调用 clearSessionCookie 清除 Cookie;前端 logout 改为 await fetch('/api/auth/logout', { method: 'POST' }) 成功后再 setUser(null)。


B3 · 投稿未关联账号,与 UI 承诺不符

functions/api/submit.ts 的 Env 仅含 DB、未读取 session,INSERT(:93-99)也不写入 submitter_gh / submitter_x(两列在表中存在但永远为 null)。但前端 SubmitForm.tsx:150 明确向用户承诺「投稿将关联到你的账号」。承诺与实际写入不一致。

建议方案:在 submit.ts 中读取 session,若已登录则按 provider 写入 submitter_gh / submitter_x;或在未实现前移除前端的关联承诺文案。


B4 · session 加密密钥派生方式有缺陷

functions/api/_session.ts:29 使用 enc.encode(secret).slice(0, 32) 作为 AES-GCM 密钥。WebCrypto 要求 AES 密钥严格为 16 / 24 / 32 字节,长度不匹配时 importKey 抛 DataError。.dev.vars.example:4 的示例值 change-me-to-random-64-chars 实测仅 28 字节,按样例配置会导致所有会话写入失败。

建议方案:对 secret 做 SHA-256 派生出固定 32 字节密钥,避免依赖人工保证长度:

const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
return crypto.subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])

并同步修正 .dev.vars.example 的示例值长度。


B5 · OAuth state 生成但从不校验

functions/api/auth/github.ts:10,16 与 x.ts:23,33 生成并随授权 URL 发送 state,但回调 github-callback.ts:15-16 仅读取后透传给 token exchange、x-callback.ts:13-14 读取后未使用,全程无存储与回比对。OAuth 登录回调缺乏 CSRF 防护,也不符合 api.md:717 对 state 绑定的要求。

建议方案:发起授权时将 state(或其 HMAC)写入短时 HttpOnly Cookie,回调阶段比对一致后才继续;可一并绑定 IP/UA 哈希以贴近契约要求。


S1 · 公开 story 站点的作者名存储型 XSS

functions/api/submit.ts:97-99 原样存储 author_name,functions/api/_build.ts:39 将其写入公开的 submissions.json,而 story/main.js:76 与 :83 把 ${authorDisplay} 直接拼接进 innerHTML。投稿者可在署名中放入脚本,审核通过后即在公开站点执行。

注:正文 markdown 在 story/main.js:98 已先 escapeHtml,正文本身不构成此漏洞;问题集中在作者名渲染路径。

建议方案:在 story 渲染端对 authorDisplay 做转义(复用 escapeHtml),或在入库 / 构建阶段对 author_name 做净化。api.md §9 对内容安全有强制要求,建议建立统一的输出转义约定。


🟠 高

C1 · functions/ 目录完全绕过类型检查

tsconfig.app.json:31 的 include 仅为 ["src"],tsconfig.node.json:23 仅含 vite.config.ts,tsconfig.json:3-6 只引用这两个工程;package.json:8 的 build 为 tsc -b && vite build。整个 functions/ 后端目录不参与任何类型检查——这正是 B1(env.ctx 误用)这类错误能进入主干的根因。

建议方案:新增 tsconfig.functions.json(引入 @cloudflare/workers-types、覆盖 functions/**),并加入 tsconfig.json 的 references,使 tsc -b 一并检查后端代码。


C2 · 内存限流不可靠

functions/api/_middleware.ts:14 使用进程内 Map 计数。Cloudflare 多 isolate 下各自独立、isolate 回收即重置,限额不可靠(同一 warm isolate 内有效,但不能作为生产限流手段)。代码顶部注释已承认这一点。

建议方案:生产改用 Cloudflare Rate Limiting 规则、KV 或 Durable Object 计数;投稿接口可叠加 Turnstile 人机校验(环境变量已预留 TURNSTILE_*)。


S2 · 临时管理员 token 长期存于 localStorage

src/pages/Admin.tsx:40 读取、:136 写入、:58 作为 Bearer 发送,后端 functions/api/admin/_middleware.ts:19-22 接受该 token。一旦发生同源 XSS(参见 S1),管理员 token 将被长期窃取,且 localStorage 中的 token 无过期机制。

建议方案:缩短临时 token 寿命、改用 HttpOnly 会话承载管理员身份,或在生产环境移除临时令牌入口(仅保留 OAuth)。


🟡 中

C3 · 运行时建表与 migrations 双轨

functions/api/_db.ts:6-28 在每个 isolate 运行 CREATE TABLE IF NOT EXISTS,而 migrations/0001_submissions.sql:3 定义了同一张表,.github/workflows/deploy.yml:28-37 却只执行 install/build/deploy,从不运行 wrangler d1 migrations apply。当前两套 schema 内容一致,但后续演进极易漂移。

建议方案:以 migrations 作为 schema 唯一真相源,在 CI 中加入 wrangler d1 migrations apply,并移除运行时建表逻辑。


C4 · 审核接口无乐观锁,且 404 文案名实不符

functions/api/admin/review.ts:60-65 的 UPDATE ... WHERE id = ? 无状态 / expectedVersion 守卫,reviewed_at 每次必更新,因此 :67-68 的「投稿不存在或状态未变更」实际只可能代表「id 不存在」。api.md:158 要求状态写接口携带 expectedVersion 做并发控制。

建议方案:引入 expectedVersion 乐观锁(或在 WHERE 中加入当前状态条件),并修正错误文案以准确反映语义。


C5 · CORS 含 localhost 且缺 Vary: Origin

functions/api/_middleware.ts:16-20 将 http://localhost:5173 / :8788 硬编码进 ALLOWED_ORIGINS 且生产构建仍带着;:32-35 / :63-66 设置了动态 CORS 头但未输出 Vary: Origin,可能导致 CDN 缓存串源。

建议方案:按环境区分白名单(生产剔除 localhost),并在响应中加 Vary: Origin。


C6 · generateId 同毫秒可能碰撞

functions/api/submit.ts:23-25 用 Date.now() + UUID 前 4 位(约 16 bit 随机),同毫秒高并发下存在主键碰撞 → INSERT 失败 → 500 的理论风险。

建议方案:直接使用完整 crypto.randomUUID() 或 ULID(后者还可顺带满足 api.md 的 ID 规范)。


C7 · 字符长度按 UTF-16 计,与契约不符

functions/api/submit.ts:32,38,50,53 用 JS .length 校验长度(UTF-16 code unit),api.md:103 要求按 Unicode 字符数计量,emoji / 代理对会被偏大计数。

建议方案:改用 [...str].length 或 Intl.Segmenter 按码点 / 字素计数。


E2 · CI 在 PR 上执行部署且无并发控制

.github/workflows/deploy.yml:3-7 在 push 与 pull_request 上均触发,:32-37 对 PR 也执行 Pages deploy 且无 concurrency;同文件未执行 D1 migration。fork PR 缺少 secrets 时会失败,部署任务也可能互相覆盖。

建议方案:将部署限定在 push 到主分支(PR 仅跑 build/lint/test),加 concurrency 取消进行中的旧部署,并补充 migration 步骤。


🔵 低(无障碍 / 响应式)

D1 · 表单标签未与控件关联

src/components/FormField.tsx:14-19 用 <span> 充当 label、错误信息为裸 <p>;调用处(如 SubmitForm.tsx:177-205)的 input / select 无 id、无 aria-describedby / aria-invalid。屏幕阅读器无法关联字段名与报错(radio / checkbox 处的局部 <label> 正常)。

建议方案:FormField 接收 htmlFor,用真实 <label htmlFor>;错误 <p> 设 id 并由控件 aria-describedby 引用,出错时设 aria-invalid。


D2 · 列表项键盘不可达

src/pages/Admin.tsx:231 用 <li onClick> 打开详情,无 role="button" / tabIndex={0} / onKeyDown,键盘与读屏用户无法进入。

建议方案:改用 <button>,或补 role/tabIndex/onKeyDown(回车、空格触发)。


D3 · 错误提示无 live region

src/components/SubmitForm.tsx:284-286、src/pages/Admin.tsx:222,290 的错误框缺 role="alert" / aria-live,动态出现时不会被播报。

建议方案:为提示容器加 role="alert" 或 aria-live="polite"。


E1 · 移动端布局需实机验证

响应式 CSS 已具备(如 src/components/SubmitForm.module.css:296 的 @media (max-width: 768px)、src/App.module.css:230),但 SubmitForm.tsx:197 的 MdEditor 固定 height: '400px'。无法仅凭代码确认移动端是否破版。

建议方案:上线前进行移动视口截图 / 实机验证,必要时让编辑器高度自适应。

…issues

- Align all endpoints to /v1/* (§1.6-§6.3): JWT Bearer auth, refresh rotation
- Unified response format §12: {data, requestId} / {error:{code,message,details}, requestId}
- Crockford Base32 ULID: contrib_, usr_, sess_, req_, rt_ prefixed IDs
- Cursor pagination with {pagination:{limit,nextCursor,hasMore}} wrapper
- Full OAuth flow §1.6.1-1.6.6: start→callback→exchange + pending registration + CSRF
- Optimistic locking §6.3: expectedVersion → VERSION_CONFLICT / INVALID_STATE_TRANSITION
- contribution_review_events audit table §15.12
- Refresh token rotation + reuse detection §1.11.2
- D1-backed shared rate limiting (was in-memory Map)
- XSS fix: escape authorDisplay in story/main.js
- a11y: real <label>, keyboard nav, role=alert on errors
- Temp admin token 24h expiry
- Unicode code-point length validation ([...str].length)
- CI: remove PR deploy, add concurrency + D1 migration step
- TypeScript coverage for functions/ via tsconfig.functions.json
@lxgy1024

Copy link
Copy Markdown
Contributor Author

代码审查问题修复总结

对照问题报告 20 项全部处理完毕:

🔴 必修(7/7 完成)

  • A1:全量对齐 apidocs.md 契约 — /v1/*、JWT Bearer、{data,requestId} 统一响应、ULID、Unix ms、cursor 分页
  • B1:waitUntil 从上下文顶层解构,审核通过后自动触发重建
  • B2:新增 POST /v1/auth/logout,清除 refresh_token HttpOnly Cookie
  • B3:投稿读取 JWT session,写入 submitter_gh/submitter_x/author_user_id
  • B4:SHA-256 派生固定 32 字节 JWT 密钥,不再依赖原始 secret 长度
  • B5:OAuth state 存入 HttpOnly Cookie + 回调比对;complete-registration 加 X-CSRF-Token 双重校验
  • S1:story 站点 authorDisplay 套 escapeHtml() 防 XSS

🟠 高(3/3)

  • C1:新建 tsconfig.functions.json,functions/ 参与类型检查
  • C2:内存 Map 限流 → D1 共享计数器(rate_limits 表 + UPSERT)
  • S2:临时管理员 token 加 24h 过期,403 自动清除

🟡 中(7/7)

  • C3:migrations 为权威 schema 源,CI 加 wrangler d1 migrations apply
  • C4:expectedVersion 乐观锁 → VERSION_CONFLICT/INVALID_STATE_TRANSITION;修正错误文案
  • C5:生产剔除 localhost,所有 CORS 响应加 Vary: Origin
  • C6:generateId → Crockford Base32 ULID(26 字符,无碰撞风险)
  • C7:字符校验改用 [...str].length(Unicode 码点计数)
  • E2:CI 移除 PR 部署,加 concurrency + D1 migration 步骤

🔵 低(3/3 + 1 待验证)

  • D1:<span> → <label htmlFor> + useId()
  • D2:列表项加 role="button" + tabIndex + onKeyDown
  • D3:错误容器加 role="alert"
  • E1:响应式 CSS 已有,需实机截图确认

新增(超额)

  • Refresh token rotation + reuse detection(§1.11.2)
  • OAuth pending registration 完整流程(§1.6.4-1.6.6)
  • contribution_review_events 审核审计表(§15.12)
  • ULID 生成器(Crockford Base32)
  • POST /v1/auth/logout-all、POST /v1/auth/refresh

Add Section 0 covering Account ID retrieval, API Token creation with required permissions, and GitHub Secrets setup for CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.
@xzdmycbx
xzdmycbx self-requested a review May 31, 2026 08:14

@xzdmycbx xzdmycbx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

代码审查问题报告(第二轮 · 复审)

总体结论:本次重写规模大、质量明显提升,第一轮多数问题确有实质修复(契约 /v1、JWT、统一响应体、游标分页、乐观锁、审计表、D1 限流、CI 等)。但存在发布阻断级回归与新缺陷:OAuth 登录端到端不可用、管理后台详情读不到正文、且 B2/B3 因前端未携带 Authorization 实为未修复。当前不可上生产。


一、第一轮 20 项修复核验(含回归更正)

编号 复核结论 证据
B1 审核重建 ✅ 已修 review.ts:25 解构 waitUntil,:106-107 调用
B2 登出 ❌ 回归·实为未修 AuthContext.tsx:99-100 调 /v1/auth/logout 不带 Bearer;logout.ts:13-16 无 JWT 直接 401,cookie 未清、token 未吊销
B3 投稿关联账号 ❌ 回归·实为未修 后端 contributions.ts:90-93 从 JWT 取身份,但 SubmitForm.tsx:82-97 不带 Bearer → author_user_id/submitter_* 仍为 null
B4 密钥派生 ✅ 已修 _jwt.ts:38-39 SHA-256 派生 HMAC key
B5 OAuth state 🟡 部分 github/start.ts:28-30、x/start.ts:48-50 写 cookie,回调比对;但未绑定 IP/UA(.claude/api.md:717)
S1 作者名 XSS ✅ 已修 story/main.js:81,88 escapeHtml(authorDisplay)
C1 functions 类型检查 ✅ 已修 tsconfig.json:3-7 引用 tsconfig.functions.json:20
C2 D1 限流 🟡 部分 _middleware.ts:53-64 用 D1;但 migration 未建 rate_limits 且中间件不 ensureSchema(见 N14)
C3 migrations 权威 🟡 部分 deploy.yml:34-39 有 apply;但与 _db.ts 双源,且漏 rate_limits
C4 乐观锁 ✅ 已修 review.ts:50-69 校验 expectedVersion,:72-91 状态守卫 + version 条件更新
C5 CORS ✅ 已修 _middleware.ts:18-21 按 Host 隔离 localhost,:44,84 Vary: Origin
C6 ULID ❌ 高估·仍有缺陷 _ulid.ts:10-11 ts & 0x1f / ts >>>= 5 32 位截断(见 N10)
C7 字符计数 ✅ 已修 contributions.ts:45,51,57,63,66 [...str].length
E2 CI ✅ 已修 deploy.yml:3-5 仅 push main、:7-9 concurrency
D1 表单标签 🟡 部分 FormField.tsx:20-27 用 <label>/role=alert,但 SubmitForm.tsx:184-273 未传 htmlFor、输入无 id/aria-describedby(N13)
D2 列表键盘 ✅ 已修 Admin.tsx:298-309 role=button+tabIndex+Enter/Space
D3 live region ✅ 已修 SubmitForm.tsx:291-292、Admin.tsx:288,380 role="alert"
E1 移动端 🟡 待验证 SubmitForm.tsx:204 编辑器仍固定 400px,需实机
A1 契约对齐 🟡 部分 /v1、统一响应、游标、审计表均落地;但 JWT/身份/注册/响应细节仍大量不符

撤销项(确认撤销正确):上一轮误报的"exchange 接口缺失""admin 路径双 /api"均不成立——functions/v1/auth/oauth/exchange.ts 存在,前端全程 /v1/*(AuthContext.tsx:34,61,82,100、Admin.tsx:111,152,166),无双前缀。


二、🔴 必修(发布阻断)

N1 · OAuth exchange 的 loginCode 哈希编码不一致 → 登录兑换恒失败

写入端 github/callback.ts:89、x/callback.ts:97、complete-registration.ts:82 把 loginCode 的 SHA-256 存为 base64url 去填充;读取端 exchange.ts:26-27 用标准 base64(带 =/+//) 查 login_codes。32 字节摘要必有 = 填充,二者恒不相等 → exchange.ts:36-37 永远返回 410。
修复:exchange.ts 改用与写入端一致的 base64url 去填充。

N2 · 首次 OAuth 用户卡在 pending_registration 死循环 → 真实用户永远登不进

OAuthCallback.tsx:26-30 对 pending_registration 仅 navigate('/submit'),全仓无任何前端调用 complete-registration;后端 github/callback.ts:70-74 只凭 refresh_tokens(status='active') 判老用户,新用户不注册就不会生成该行 → 每次登录都是新用户。
修复:实现注册补全页对接 complete-registration;或在"仅 OAuth 即登录"产品设定下,回调对新用户直接建号发 loginCode。

N3 · is_admin 无从置位(org 校验被删)→ 无人能成管理员

complete-registration.ts:65 硬编码 false;github/callback.ts:6-10 声明 GITHUB_ORG 却无任何组织成员校验;全仓无把 is_admin/roles 置 admin 的路径(TEMP_ADMIN_TOKEN 除外)。
修复:在 GitHub 回调恢复 org 成员校验并据此写 is_admin。

N4 · 管理后台详情读不到正文、署名错乱(命名不匹配)

详情接口 admin/contributions/[id].ts:31-38 返回 camelCase(contentRaw/authorType/authorName/submitterGh),前端详情 Admin.tsx:352-369 用 snake_case(content/author_type/author_name/submitter_gh)→ 正文恒空、署名显示"undefined(笔名)"。审稿员无法阅读正文。
修复:详情渲染统一兼容映射,或前后端命名对齐(列表页已用 getField 兼容,详情页照搬即可)。

B2(回归)· 登出未真正生效

AuthContext.tsx:99-100 不带 Authorization → logout.ts:13 getSession 为 null → 401,refresh cookie 未清、token 未吊销。刷新后 AuthContext.tsx:34 的 /v1/auth/refresh(带 cookie)会恢复登录。
修复:logout 携带 Authorization: Bearer <accessToken>;或将 logout 改为仅凭 refresh cookie 即可吊销(无需 JWT)。

B3(回归)· 登录用户投稿仍不关联账号

SubmitForm.tsx:82-97 提交不带 Authorization → 后端取不到 JWT → author_user_id/submitter_* 仍 null。UI 承诺"投稿将关联到你的账号"(SubmitForm.tsx:157)落空。
修复:投稿请求带上 Authorization: Bearer <accessToken>(在 AuthContext 暴露统一的带 token fetch 封装,供 SubmitForm/logout/Admin 共用)。

N14 · 生产 rate_limits 表可能不存在 → 所有写接口 500

_middleware.ts:53-64 在 next() 之前写 rate_limits,但中间件不调 ensureSchema,而 migrations/0001_submissions.sql 未创建该表。纯 migration 部署的库上,若首个请求是 POST /v1/contributions,中间件 INSERT 命中不存在的表 → 抛错 → 500(顺序相关:需先有 GET 触发 _db.ts:86 建表才"碰巧"可用)。
修复:把 rate_limits 加入 migration;中间件做容错或显式初始化。


三、🟠 高 / 🟡 中

N5 · 身份不稳定 + 无 users/oauth_accounts 表(更正:触发点是登出而非过期)

身份派生自 refresh_tokens(_db.ts:58-70),无持久用户表。登出 logout.ts:18 吊销全部 token 后,回调 github/callback.ts:70-74 将同一 OAuth 用户视为新用户、分配新 usr_<ULID>,断裂 author_user_id 与 admin 身份。

复核更正:原报告"7 天过期必判新用户"不准确——existingUser 查询只过滤 status='active'、不过滤 expires_at,过期但仍 active 的行仍会被当作老用户。真正触发点是登出/吊销。
修复:引入 users/oauth_accounts 表,以 (provider, providerUserId) 作稳定身份键。

N17 · 用 username 而非 providerUserId 判老用户 → 身份串号风险

github/callback.ts:66-74、x/callback.ts:75-83 以用户名匹配老用户,而 refresh_tokens 不存 provider_user_id(_db.ts:58-70)。GitHub/X 用户名可改、可被他人复用 → 改名后丢身份、用户名被回收后可能冒认他人。
修复:以 provider_user_id 作为匹配键并入库。

N18 · complete-registration 未实现账户注册契约

complete-registration.ts:34-40 读取 username/email/password 但无任何校验、无密码哈希、无 users 落库;:62-87 仅建 refresh token / loginCode。与 .claude/api.md §1.6.4 的注册语义不符,且与 N2/N5 叠加使注册流程实际不可用。
修复:补全字段校验、argon2/bcrypt 密码哈希、users 与 oauth_accounts 落库与唯一性约束。

N16 · oauth_pending_csrf Cookie 路径使前端补全注册读不到

complete-registration.ts:28-31 要求 X-CSRF-Token 等于 oauth_pending_csrf cookie,但该 cookie 的 Path=/v1/auth/oauth(github/callback.ts:119),而注册页在 /auth/callback(或 /submit)下,JS 读不到该 cookie → 无法构造 CSRF 头 → 注册必失败。
修复:将 csrf cookie 路径放宽到前端可读范围(如 Path=/),或改由 pending-profile 接口下发 csrf 值。

N6 · refresh 复用检测吊销了空用户

refresh.ts:30-33 检测到复用后调用 revokeRefreshTokens(env.DB, ''),_session.ts:193-195 按 user_id = ? 更新,空串命中不到任何行 → 复用检测形同虚设。
修复:先查出该 token 的真实 user_id 再全量吊销。

N7 · access token 无法即时失效

tokenVersion 创建时恒为 0(github/callback.ts:84、complete-registration.ts:74),getSession(_session.ts:63-80)只验签名/过期、信任 payload,不校验 session/revoked/tokenVersion。登出/封禁无法即时使已签发 JWT 失效(最长 15 分钟窗口)。
修复:引入 tokenVersion/会话吊销表并在鉴权时校验,或缩短 access token 寿命并接受窗口。

N8 · 限流粒度过粗、缺标准响应头

_middleware.ts:89-93 所有 POST/PATCH(login/refresh/exchange/submit)共用单一 10 次/小时/IP(:7);NAT 后多人互相挤占、token 每 15 分钟 refresh 也吃额度;429 仅 errorResponse,无 Retry-After/X-RateLimit-*(契约要求)。
修复:按接口/动作分桶并差异化阈值;429 补标准限流头。

N15 · 临时管理员 token 入口实际不可用

Admin.tsx:198-241 在 !user 时永远渲染登录框(即便 tempToken 已设置),非 admin 用户又被 :245-256 拦截。该过渡入口形同虚设。
修复:!user 分支考虑 tempToken 存在时进入后台,或干脆移除该入口(结合 N3 的 admin 方案)。


四、🔵 低

  • N9 · "重新审核为通过"按钮无效:Admin.tsx:402-409 对非 pending 调 review,review.ts:72-76 仅允许 pending/in_review → 409。建议移除该按钮或扩展后端状态机。
  • N10 · ULID 时间编码错误:_ulid.ts:10-11 JS 位运算把 Date.now() 截断到 32 位,时间前缀不符规范、单调性不可信(唯一性仍 OK,排序另有 created_at)。建议改用 48 位时间编码。
  • N11 · 双 schema 源 + 漏表:_db.ts:7-107 运行时建表与 migrations/0001 两套,且 migration 漏 rate_limits(见 N14)。建议以 migrations 为唯一源。
  • N12 · rotateRefreshToken rotated 分支(部分成立):顺序路径下 refresh.ts:19 先 verifyRefreshToken 对非 active 返回 null,不进入该分支;但并发下(verify 后被另一请求 rotate)_session.ts:153 仍可能触发,非绝对死代码。建议梳理该并发语义。
  • N13 · FormField 关联未闭环:FormField.tsx:14-27 生成 fieldId/errorId 却未注入子元素,SubmitForm.tsx:184-273 也未传 id/aria-describedby。建议让 FormField 通过 cloneElement 或显式 props 完成关联。
  • N19 · cursor 非法导致 500:admin/contributions.ts:27-29 直接 atob(cursor),异常落到 :96-99 返回 500;limit(:15)未防负数。建议校验 cursor 与 limit 下界。
  • N20 · honeypot 失效:隐藏字段在 SubmitForm.tsx:174-181,但提交 body(:85-96)根本不发送 website,反爬逻辑空转。建议补发该字段或移除。
  • N21 · loginCode 消费非原子:exchange.ts:29-37 SELECT 与 :51-52 DELETE 分离,修复 N1 后并发可能重复兑换。建议用条件 DELETE 返回行数判定单次消费。
  • 契约细节:未绑定 state 的 IP/UA(.claude/api.md:717,成立);JWT 用 HS256(_jwt.ts:33)——仓库可见契约未明确要求 EdDSA,该项无法据库内证据判定违规,存疑。

五、复核更正记录(透明度)

  • N5:触发点应为"登出/吊销",而非"7 天过期";existingUser 查询不过滤 expires_at。
  • N12:并非绝对死代码,并发下可达。
  • JWT EdDSA:仓库可见契约未找到该要求,撤回"违反 EdDSA"表述,仅记 HS256 现状存疑。
  • 第一轮误报"exchange 缺失/双 /api"维持撤销。

lxgy1024 added 2 commits May 31, 2026 20:25
…API alignment

- Remove deprecated Cloudflare Pages Functions backend (replaced by Express+MySQL)
- Fix B2: logout now carries Authorization header to backend
- Fix B3: submission now carries Authorization header to associate with user account
- Fix N2: add Register.tsx page for OAuth pending_registration flow
- Fix N4: Admin detail view uses camelCase field names from API response
- Fix N15: temp admin token login path now works when OAuth user is null
- Fix N9: remove non-functional re-review button (backend state machine restriction)
- Fix N13: FormField auto-injects id/aria-describedby/aria-invalid via cloneElement
- Fix N20: honeypot website field included in submission request body
- Fix D1/D2/D3: label association, keyboard navigation, live region announcements
- Add rate_limits table to D1 migration 0001 for shared rate limiting
- Clean up wrangler.jsonc: remove redundant env blocks, add vars documentation
- Add migration 0002: users + oauth_accounts supplementary tables
- Remove D1 migrations, wrangler d1_databases, tsconfig.functions.json
- Remove dead wrangler.jsonc vars (GITHUB_ORG etc now in Express backend)
- Remove D1 migration step from deploy.yml
- Add src/config.ts: API_BASE switches between Vite proxy (dev) and api.transcircle.org (prod)
- Update all 11 fetch calls to use API_BASE
- Rewrite DEPLOY.md for Express+MySQL architecture
- Clean up TODO.local.md
@lxgy1024

lxgy1024 commented May 31, 2026 •

Copy link
Copy Markdown
Contributor Author

本次修改:
1.新增 Register.tsx 补全注册页(N2);OAuthCallback 引导至注册页而非卡死循环
2.AuthContext logout 带 Bearer(B2);SubmitForm 投稿带 Authorization(B3)
3.详情字段 camelCase 兼容(N4);tempToken 入口修复(N15);移除无效重审按钮(N9)
4.FormField cloneElement 注入 id/aria-describedby/aria-invalid(N13/D1);列表 role=button+键盘(D2);错误框5.role=alert(D3)
6.honeypot website 字段补发(N20);作者名 XSS escape(S1)
7.Migration 补充:rate_limits 表(N14)、users/oauth_accounts 补充表(N5)

旧后端已被 MySQL 替代!

部署前需要:
1.在 GitHub Secrets 里填上 CLOUDFLARE_API_TOKEN 和 CLOUDFLARE_ACCOUNT_ID
2.在 functions/config.toml 里填上:
(1) SESSION_SECRET(JWT 签名密钥)
(2) MySQL 的 DB_PASSWORD,DB_HOSTNAME,DB_HOSTNAME,DB_DATABASE
(3) GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,X_CLIENT_ID,X_CLIENT_SECRET,用于 OAuth 登录
(4) 一个 TEMP_ADMIN_TOKEN(?)
(5) STORY_REPO 和 STORY_REPO_TOKEN (故事站仓库名和相应 github 构建 token)

…cle#2)

Add Express 5 backend replacing Cloudflare Pages Functions:
- MySQL schema with users, oauth_accounts, sessions, contributions tables
- JWT auth with refresh token rotation and session management
- GitHub/X OAuth with PKCE, CSRF protection, IP/UA binding
- Per-action rate limiting with standard headers
- Cursor-based pagination, optimistic lock review, audit trail
- Honeypot anti-abuse, validation with Unicode-safe character counting

Fix B5: OAuth state IP/UA binding hard check — reject callback if DB
record missing instead of silently skipping binding verification.
Clean up consumed state records on success.

Config: eslint ignores functions/, wrangler excludes functions/ from pages build,
DEPLOY.md rewritten for Express+MySQL architecture.
lxgy1024 added a commit to lxgy1024/TransCircle-Frontend that referenced this pull request Jun 1, 2026
…omprehensive audit report

Fix useState() called inside conditional branch in Admin.tsx (moved to component top level).

Add TODO.local.md with full code review verification report covering all 41 issues
from issue report TransCircle#2 — confirm all items are resolved after Express+MySQL migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omprehensive audit report

Fix useState() called inside conditional branch in Admin.tsx (moved to component top level).

Add TODO.local.md with full code review verification report covering all 41 issues
from issue report TransCircle#2 — confirm all items are resolved after Express+MySQL migration.
lxgy1024 added 8 commits June 1, 2026 22:07
- JWT: Ed25519(EdDSA) + 规范 payload (iss/aud/sub/jti/sid/roles/iat/exp)
- Password: PBKDF2 → argon2id (memory 64MiB, timeCost 3)
- Logout: 不 bump tokenVersion, 返回 204
- Refresh: 正确使用 rotateRefreshToken, 不创建新 session
- Contribution schema: 删除 authorType/authorName/contact/category/IPhash
- OAuth callback: 补齐全部 10 种 status 分流
- Audit logs: 写入 contribution.review + contribution.create
- Health: 规范 data 信封格式
- Middleware: isAdmin → roles[]
- Session: RotateResult 返回值含 newRefreshToken
- Database: 修复 mysql2 ExecuteValues 类型错误
Phase 2 — Auth endpoints:
- POST /auth/register + email verify/resend
- POST /auth/login (password login with MFA detection)
- POST /auth/password/forgot + reset, POST /me/password
- TOTP setup/enable/disable/verify/recovery-codes
- Session management (GET /auth/session, /me/sessions, logout-all)
- Step-up start/verify
- OAuth bind/unbind/query, account merge

Phase 3 — Admin endpoints:
- POST /admin/contributions/:id/publish, hide, restore, delete
- GET review-events, GET stats
- GET /admin/users list/detail, ban/unban
- GET /admin/audit-logs

Phase 4 — Public endpoints:
- GET /public/contributions (list + detail)

Phase 5 — Content sanitization:
- HTML sanitizer + Markdown renderer

All new files pass tsc --noEmit and eslint.
- Login: MFA flow with TOTP input after mfaRequired detection
- AuthContext: OAuth start with redirectAfter query param
- StepUpDialog: universal step-up component (password method)
- OAuthBinding: step-up dialog on STEP_UP_REQUIRED
- OAuthMerge: step-up dialog on STEP_UP_REQUIRED
@xzdmycbx
xzdmycbx requested review from xzdmycbx June 14, 2026 07:33

@xzdmycbx xzdmycbx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总体结论:无阻断项。已实测 tsc -b --noEmit 与 tsc -p tsconfig.app.json 均通过,可正常构建。以下为当前仍存在的问题,建议处理"必修(中)"几条后合并,其余作为跟进。

需先说明一处部署背景:前后端为同源部署(Express 同时 serve 前端产物 + API,config.ts 恒为 /v1)。在此模型下,后端把 oauth_pending_csrf cookie 的 Path 从文档规定的 /v1/auth/oauth 改为 /、并让 complete-binding 额外接受 ?provider= query——这两处前后端已协同工作,但偏离 api.md 原文,需回写文档(见下文 D1/D2),否则后续维护者按文档"纠正"会改坏。


必修(中)

1. Login.tsx:74-79 — MFA 错误码判断与 api.md §1.9.4 不符,输错验证码会把原始错误码显示给用户

代码判断 INVALID_CODE / INVALID_MFA_CODE / INVALID_RECOVERY_CODE,但 api.md §1.9.4(行 2308-2309)与 src/api/errors.ts:43-44 中真实码是 INVALID_TOTP_CODE(422)和 TOTP_CODE_REPLAY(422)。结果:用户输错验证码时真实码不命中任何分支,落到第 79 行 setError(result.errorCode || ...),屏幕直接显示字符串 "INVALID_TOTP_CODE"。应改判 INVALID_TOTP_CODE 并补 TOTP_CODE_REPLAY 处理。

2. Admin.tsx:430-433 + router/index.tsx — 编辑申请入口对 reviewer 隐藏,且管理子路由无守卫

编辑申请投票后端要求 requireReviewer + contribution:edit-request:vote(edit-requests.ts:382),api.md §10.6(行 5741-5755)同样要求该权限,即这是 reviewer 职责。但 Admin.tsx:430-436 把"用户管理 / 编辑申请 / 审计日志"三个入口统一放在 isFullAdmin(仅 admin)之后——reviewer 看不到"编辑申请"入口。编辑申请链接应改为 isAdmin 门控(用户管理 / 审计日志保留 isFullAdmin 合理)。

此外 router/index.tsx 无路由级守卫;AdminUsers / AdminEditRequests / AdminAuditLogs 三页自身也没有未登录/无权限的拒绝 UI(未登录时 effect 直接 return,停在空列表),只靠后端 403 兜底。建议加 RequireAdmin 守卫或补三页拒绝 UI。

3. client.ts:243-252 + SubmitForm/RegisterDirect/EditRequestForm — Idempotency-Key 失败路径不清理,存在跨意图泄漏;login 不应携带该 key

三个创建表单只在成功出口 setIntentKey(null)(SubmitForm.tsx:181、RegisterDirect.tsx:93、EditRequestForm.tsx:44),失败 return 与 catch 分支都不清。叠加 client 的"自动持久化"(idempotent:true 但未显式 setIntentKey 的请求会把自动生成的 key 写回全局 _intentKey 且无人清理),理论链路是:某次创建失败 → _intentKey 残留 → 下一个不同 body 的 idempotent 请求复用同一 key → 服务端 409 IDEMPOTENCY_KEY_MISMATCH。

当前后端下该 409 不会实际触发:/auth/login(auth-login.ts:76)与浏览器版 /oauth/complete-registration(auth.ts:1008)都没挂 idempotencyKey 中间件,而真正强制幂等的 /register、/contributions、native-complete-registration 三个端点都在请求前 setIntentKey(新key) 覆盖。但这仍是潜在正确性 bug(一旦后端按 api.md 更严格启用幂等,或有人新增 idempotent:true 而忘记 setIntentKey 即复现),且 loginWithPassword(AuthContext.tsx:114)携带 idempotent:true 本身偏离 api.md §幂等性(行 155-160,只有"创建资源 POST"纳入幂等范围)。建议:把 setIntentKey(null) 移入 finally,并去掉 loginWithPassword 的 idempotent:true。

4. OAuthCallback.tsx:45-52 — exchange 失败仍静默跳转,用户以为已登录

const user = await exchangeLoginCode(loginCode) 失败返回 null(AuthContext.tsx:178),代码未判 null 直接进入 safeRedirect 并 navigate('/submit')。api.md §1.6.3(行 1036)的 410(loginCode 过期/已用)、401 错误下,用户被静默带到投稿页而非提示重新登录。应在 user === null 时跳 /auth/error。

5. Home.tsx:33-64 — 首页搜索仅过滤当前页,比不工作更具误导性

fetchList 只取 limit=20,过滤在拿到这 20 条后于本地做(:43-49)。第 21 条起的投稿永远搜不到;当前页无匹配时显示"暂无已发布的投稿",用户会误以为全站没有。search 也不在 api.md §5.1 契约(行 4106-4110 仅 limit/cursor/language/tag/sort)。建议:删除搜索框,或明确提示"仅在已加载结果中筛选",或与后端确认新增搜索契约后改服务端搜索。

6. Admin.tsx:605-613 — published 状态直接提供"删除"按钮,违反 §6.6 状态机

api.md §6.6(行 4689)状态流转明确:"pending / in_review / published 状态必须先 withdraw 或 hide 后才允许删除"。但前端在 selected.status === 'published' 时同时渲染"隐藏"与"删除"两个按钮,用户可对已发布投稿直接点删除,按契约应返回 INVALID_STATE_TRANSITION。hidden 状态显示删除是合规的;published 应只显示"隐藏"。(注:后端提交 4a9648d 也放行了直接删除 published,即前后端共同偏离 §6.6;若产品确实要允许,应回写文档,否则前端移除该按钮。)


应修(低)

7. AdminEditRequests.tsx:63,187-196 — 投票历史(§10.5 votes.history)永不展示

详情已按 §10.5 读取嵌套的 contribution/proposed/votes/myVote 并展示票数,但"投票记录"块依赖独立 state votes,该 state 从不被赋值(只在返回列表时 setVotes([])),votes.length > 0 永远为假。api.md §10.5(行 5692-5724)的 votes.history[] 完全无法显示。应从 detail.votes.history 读取渲染;VoteResult 接口中的 reviewerUserId 也与文档 reviewerId 不符。

8. story/main.js — "阅读全文"无实际效果、分类过滤失效、author_type 硬编码

列表接口不返回正文,content 取自 item.summary(:55),toggleStory(:192)只展开已有摘要、从不请求 §5.2 详情,因此"阅读全文"展开后显示的仍是摘要而非正文。CATEGORIES(:3)硬编码三类而 category 取自自由文本 tags[0](:55),除"全部"外基本不出现。author_type:'real'(:57)写死导致永远显示"(实名)",§5.1 无此字段。

9. i18n — zh-TW 落后 zh-CN 约 17 个 key,有占位乱码;语言硬编码

zh-TW 缺失(代码在用、靠 fallbackLng 回落简体):admin.networkError、login.errors.{banned,deleted,emailNotVerified,locked,merged,pendingDeletion}、login.{mfaTitle,mfaDescription,mfaExpired,mfaExhausted,mfaInvalidCode,mfaInvalidRecoveryCode,mfaVerifyFailed}、registerDirect.errors.{passwordContainsEmail,passwordContainsUsername};zh-TW/common.json:83 的 admin.tabs.inReview 为 "???"。ErrorBoundaryPage 用的 common.backToHome/errorBoundaryTitle/errorBoundaryDescription、notFound.description 在两份 locale 均缺(有内联中文兜底,运行不报错)。i18n/config.ts:17 仍硬编码 lng: 'zh-CN' 且无切换 UI,zh-TW 资源实际加载不到。

10. SettingsSecurity.tsx:604-610 — 未登录访问 /settings/security 永久卡在"验证身份…"

守卫只判 !authUser,没用上已解构的 authLoading(:81)。当 auth 初始化结束且确实未登录时,页面永久停在 t('admin.verifying'),既不跳登录也不提示。应区分 authLoading(转圈)与"已确定未登录"(跳 /login)。

11. i18n 删除账户文案与 §2.4 矛盾

zh-CN/common.json:307、zh-TW/common.json:290 写"账户将在 7 天后自动注销",而 api.md §2.4(行 3463)deletionScheduledFor = now + 30d,冷静期是 30 天。文案数字错误。

12. SubmitForm.tsx:109-111 — 图片插入读闭包旧 content,连续插入/边传边打字可能丢内容

set('content', form.content + ...) 读的是闭包旧值。set 本身基于函数式 setForm,但此处先读闭包再传值绕过了它。应改为 setForm(prev => ({ ...prev, content: prev.content + ... }))。

13. OAuthMerge.tsx:44,62-64 — 合并成功后未刷新内存 token,跳转后首个请求可能 401

api.md §1.8(行 2085-2086)合并会递增主账号 tokenVersion 使旧 access token 立即失效;OAuthMerge 成功后只 clearCsrfToken() 跳 /submit,未 tryRefreshToken(),跳转后首个请求可能 401(靠自动 refresh 侥幸救回)。建议成功后显式刷新 token。(绑定 complete-binding 不递增 tokenVersion,不存在该 token 问题;OAuthBinding.tsx:69 仅是成功后跳 /submit 而非回 /settings/security、未重拉 /me/oauth 刷新绑定列表,属体验小问题。)


低优先 / 残留

  • L1 Retry-After 未展示:client 已解析 rateLimit.retryAfter,但没有任何页面读取它提示等待时长,触发 429 时用户得不到重试时间。
  • L7 title 未做 Unicode 长度校验:summary/tag 已用 [...str].length,但 title 仅靠 maxLength={120}(按 UTF-16 计),MyContributionDetail / EditRequestForm 同样。api.md 通用约定(行 103)要求按 Unicode 字符计。
  • L8 details 映射仅 SubmitForm:EditRequestForm、RegisterDirect 仍只显示通用 message,未把 error.details[{field,reason}] 映射到字段。
  • L14 uploadFile 401 重试丢 signal:uploadFile 的 401 重试 fetch 未带 signal,上传中取消无法中断重试请求。
  • handleReview 缺 VERSION_CONFLICT 恢复:其余管理操作冲突后会重拉详情刷新 version,但 Admin.tsx 的 handleReview 未加该恢复路径,§6.3 同样返回 409。

风险登记(不强制):

  • WebAuthn rawId: pkCred.id(SettingsSecurity/StepUpDialog/AuthContext):pkCred.id 恰为 base64url 故可用,但规范写法应对 pkCred.rawId 编码。
  • 删除账户用原生 prompt() 收集密码(SettingsSecurity.tsx:264):体验差且密码明文驻留 JS 字符串,建议改对话框内输入。
  • PublicContributionDetail.tsx:69 dangerouslySetInnerHTML(contentHtml):§5.2 契约保证服务端已 sanitize,符合约定;前端无二次 DOMPurify,属可接受的信任边界。
  • Navbar 死锚点 #archive/#community("首页"还指向外站),点击无效。
  • CSS 无 prefers-color-scheme 兜底:禁用 JS 的访客拿不到暗色(JS 环境 FOUC 已由 index.html 内联脚本解决)。

文档需回写(实现已工作但偏离 api.md)

  • D1 oauth_pending_csrf cookie 实际 Path=/(auth.ts:725,791),api.md §1.6.2 行 726 写 Path=/v1/auth/oauth。同源部署下前端需从任意页面读取该 cookie,Path 必须放宽,请更新文档(Path=/ 会把 CSRF token 附带到全站同源请求,略微扩大暴露面,但本就是非 HttpOnly 双提交设计,可接受)。
  • D2 complete-binding 实际通过 ?provider= query 区分 provider(auth.ts:269 + OAuthBinding.tsx:38),api.md §1.6.5 行 1192-1205 未含该 query(文档要求由 cookie 自身识别)。前后端已协同,请同步文档。

建议处理顺序

  1. 第 1 项 MFA 错误码改 INVALID_TOTP_CODE/TOTP_CODE_REPLAY(用户每次输错都看到裸码,影响明显)。
  2. 第 2 项 编辑申请入口改 isAdmin 门控,并补管理子路由守卫或拒绝 UI。
  3. 第 3 项 intent-key 移入 finally + 去掉 login 的 idempotent:true。
  4. 第 4 项 OAuthCallback exchange 失败跳错误页;第 10 项 SettingsSecurity 用 authLoading 区分未登录。
  5. 第 5 项 首页搜索方案二选一;第 6 项 移除 published 状态下的删除按钮(或回写 §6.6)。
  6. 第 7–13 项与低优先残留作为跟进;D1/D2 回写 api.md。

剩余问题集中在错误码细节、角色入口、i18n 完整性与少量交互边界,工作量不大。建议补关键流程(登录 / MFA / 编辑申请审核)的最小集成验证,避免同类"路径未端到端跑通"的问题复发。

lxgy1024 added 8 commits June 14, 2026 15:53
- Home: remove client-side search filter

- story: remove hardcoded author_type

- L1: show retry-after in 429 msgs

- L7: unicode title length validation

- webauthn: proper rawId encoding

- delete account: inline password dialog
- OAuthBinding: redirect to /settings/security after success
- story/main.js: remove double-escape from renderMarkdown(contentHtml)
- RegisterDirect: map VALIDATION_ERROR details to form fields
- EditRequestForm: map VALIDATION_ERROR details to form fields
- Retry-After: show in UI via result.error.message
- Remove maxLength HTML attr, rely on Unicode-aware JS truncation

@xzdmycbx xzdmycbx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

必修

1. [高] story/main.js:85-88 — 分类筛选栏把用户可控 tag 未转义注入 innerHTML(XSS)

renderFilters 用模板串拼 nav.innerHTML:

nav.innerHTML = available.map(cat =>
  `<button class="filterBtn ..." data-cat="${cat}">${cat}${...}</button>`
).join('')

cat 来自 s.category = item.tags?.[0](:54),即投稿者可控的标签文本(经 §5.1 返回),data-cat="${cat}" 属性值与 ${cat} 文本都未经同文件的 html() 转义。形如 "><img src=x onerror=...> 的标签即可逃逸属性、注入脚本。对比同文件 storyCard(:127-130)对 category 都走了 html(),唯独筛选栏遗漏。修法:data-cat="${html(cat)}" 且文本用 ${html(cat)}。

2. [中] 5 个 i18n key 缺失,运行时向用户渲染裸 key

以下 key 在代码中被 t() 使用、且均未传第二参数默认值,但 zh-CN 与 zh-TW 两份语言包都不存在(i18n/config.ts 未配置 missingKey 兜底,react-i18next 会直接回显 key 字符串)。已按 JSON 路径精确核对(排除 settings.deleteAccount.confirm、settings.stepUp.cancel、login.backToLogin 等嵌套同名项):

  • settings.passwordPlaceholder — SettingsSecurity.tsx:1400(删除账户密码框 placeholder)
  • settings.confirm — SettingsSecurity.tsx:1410(确认按钮)
  • settings.cancel — SettingsSecurity.tsx:1416(取消按钮)
  • common.backToLogin — ForgotPassword.tsx:46,71(common 下只有 backToHome)
  • admin.tempAdminHint — Admin.tsx:448(admin 下有 tempAdmin 无 tempAdminHint)

需在两份 locale 补齐这 5 个 key。

3. [中] SubmitForm.tsx:175-188 — 校验错误仅含未识别字段时,用户看不到任何提示

VALIDATION_ERROR 分支把无法映射到表单字段的错误写入 fieldErrors._fallback(:183),随后只 setErrors(fieldErrors)(:185),不 setServerError。但 _fallback(声明于 :40)在整个组件没有任何渲染处:字段级 FieldError 只渲染 title/content/summary/tags/agreement,全局错误框(:380)仅在 serverError 非空时显示。因此当后端返回的 error.details 只包含未识别字段(如 contentFormat/language/submitMode)时,用户点提交后表单静默无反应(status 已是 error 但无可见错误)。依据 api.md §12 错误格式要求把 details[{field,reason}] 反馈给用户。修法:把 _fallback 渲染到错误框,或回退为同时写入 serverError。


低优先

  • 根 tsconfig.json 引用了无法编译的 tsconfig.functions.json:根 tsconfig.json 的 references 含 ./tsconfig.functions.json,而 functions/src 在该配置(ES2022/lib dom)下有 Uint8Array<ArrayBufferLike> 等类型错误(auth-passkey.ts:521、auth-stepup.ts:321、images.ts:590、crypto.ts:32),导致裸 tsc -b 与 IDE "build solution" 失败。pnpm typecheck/pnpm build 因脚本显式只列 app+node 而不受影响,functions 自身的 tsc 也通过。属配置不一致,建议从根 references 移除 functions,或修正 functions 类型错误使其在该配置下也能编译。

  • story/main.js:203-205 故事详情正文用裸 innerHTML 渲染 contentHtml:React 端 PublicContributionDetail.tsx:72 已对 contentHtml 加 DOMPurify.sanitize,但 story 子站(原生 JS)仍 contentEl.innerHTML = contentHtml 无前端清洗。§5.2 契约保证服务端已 sanitize,故合规;但同一站点两条渲染路径的纵深防御标准不一致,建议 story 侧同样补一层清洗。

  • RootLayout.tsx:49 限流提示文案不当:setRateLimitToast(\${t('admin.networkError')} — 请在 ${retryAfter} 秒后重试`)把 429 限流复用成admin.networkError`("网络错误")语义错配,且"— 请在 X 秒后重试"为硬编码中文,zh-TW 下不翻译。建议新增带插值的专用限流 i18n key。

  • 删除账户文案自相矛盾:zh-CN/common.json:433 及 zh-TW 同行写"此操作不可撤销",但成功文案(:437)是"30 天后自动注销",且 api.md §2.5 提供撤销注销流程——删除存在 30 天冷静期、可撤销,"不可撤销"措辞误导用户。

  • AuthContext.tsx refreshUser 未把新 token 同步回 React state:refreshUser 调 tryRefreshToken() 仅更新 client 内存 token,未 setAccessToken(newToken)。合并/会话刷新后若停留同一 React 树,以 accessToken 作 prop/判断的消费者(StepUpDialog、step-up 触发判断)会拿到旧值。多数 API 走 client 内存 token 不受影响,实际危害小,但属状态不一致。

  • SubmitForm 蜜罐字段失效(死代码):提交 body 已移除 website(符合 §3.1 无此字段),但表单仍渲染该 honeypot 输入,其值不再发送任何地方,反爬蜜罐失效。建议彻底删除该输入或与后端确认后恢复发送。

  • Login.tsx:98 仅 recovery_code(无 totp)的 MFA 用户无法输入验证码:hasTotp 收窄为仅 includes('totp'),验证码输入框与确认按钮只在 hasTotp 时渲染。若后端 availableMethods 仅含 recovery_code 而无 totp,用户将无输入入口。实践中 recovery 通常随 totp 一并返回,概率低,属边界。

  • RequireAdminLayout 守卫颗粒度只到 reviewer:RequireAdminLayout.tsx:30 统一放行 admin||reviewer,而 /admin/users(§7 user:read)、/admin/audit-logs(§8 audit:read)按 §15.10 是 admin 专属。reviewer 可经守卫进入这两页路由,仅靠页面内 isFullAdmin 拒绝 UI 兜底(不越权,后端也有 403),但守卫层语义不精确。

  • 三个管理列表页缺竞态守卫:AdminUsers/AdminEditRequests/AdminAuditLogs 的列表拉取无 Admin.tsx 那样的 fetchSeq 序号守卫,快速连点搜索/翻页时先发后到的响应可能覆盖结果。

  • CSS 无 prefers-color-scheme 兜底:src/styles/index.css 只用 [data-theme] 选择器,禁用 JS 的访客拿不到暗色(JS 环境 FOUC 已由 index.html 内联脚本解决)。

  • 首页搜索为"已加载列表本地过滤":Home.tsx 对已加载项做本地匹配并有"本地筛选"UI 提示,不发非契约参数,属契约无服务端搜索下的妥协方案(仅提示,非缺陷)。

- story/main.js: XSS — html() escaping + sanitizeHtml()
- i18n: add 5 missing keys (passwordPlaceholder, confirm,
  cancel, backToLogin, tempAdminHint)
- SubmitForm: _fallback errors now set serverError
- tsconfig.json: remove functions from references
- RootLayout: rate-limit toast uses i18n key
- zh-CN/TW: fix delete account description (revocable)
- AuthContext: refreshUser calls setAccessToken(newToken)
- SubmitForm: remove dead honeypot (website) code
- Login: MFA input shows when mfaMethods.length > 0
- RequireAdminLayout: restrict to admin-only + layered
  guards (RequireReviewerOrAdminLayout, AdminOnlyGuard)
- AdminUsers/EditRequests/AuditLogs: add fetchSeq guards
- index.css: add prefers-color-scheme dark fallback
- Home.tsx: search expansion with multi-page prefetch
@xzdmycbx

Copy link
Copy Markdown
Collaborator

仍有如下问题

中

1. Home.tsx:61-89 — 深链进入 ?search=... 时搜索只过滤首页(cursor 闭包陈旧)

搜索改为"命中搜索词时自动向后展开最多 5 页再本地过滤"。但展开 effect(:67-89)依赖数组只有 [searchTerm],内部 let currentCursor = cursor(:76)读的是闭包里的 cursor。深链直接打开 /?search=foo 时:初始加载 effect(:61-65)与展开 effect 同时在挂载时运行,此刻 cursor 仍是初始值 null,while (currentCursor ...) 不执行 → 不展开;随后 doInitialLoad 把 cursor 设为第 2 页游标,但展开 effect 因 searchTerm 未变不会重跑。结果深链搜索只对首屏 20 条生效,与"展开 5 页"的设计不符。

2. Home.tsx:76-82 + 174-185 — 搜索展开未 setCursor,清空搜索后"加载更多"重复拉取并产生重复 key

展开循环只更新局部变量 currentCursor(:81),从不调用 setCursor,因此 cursor state 始终停留在初始加载设定的第 2 页游标。当用户清空搜索后,"加载更多"按钮(cursor && !searchTerm)用这个旧游标再次 fetchPage(cursor),会重复拉取已在展开阶段加载过的第 2 页,setItems(prev => [...prev, ...]) 追加后产生重复条目与重复的 React key(item.id)。


低

3. Home.tsx:68,77-87 — 展开用单个 expandAbort ref,快速切换搜索词/StrictMode 下有竞态

每次 effect 运行开头 expandAbort.current = false(:68),而 cleanup 里设 true(:87)。搜索词快速变化(foo→foob)时,旧 effect 的 cleanup 刚把 abort 置 true,新 effect 立即又重置为 false,旧的异步展开循环再检查 expandAbort.current(:77,79)已变回 false → 旧请求仍会 setItems 追加并改 expanding,与新一轮展开交错。StrictMode 下 effect 双跑同理。建议改用每次运行独立的 abort 标志或序号守卫。

4. story/main.js:189-214 — 新增的 sanitizeHtml() 清洗偏弱

详情正文已改为 sanitizeHtml(contentHtml) 再写 innerHTML,方向正确;但实现只删 on* 事件属性、对 <a> 仅拦截小写且无前导空白的 href.startsWith('javascript:')。未处理:style 等任意属性保留(可 CSS 注入)、JaVaScRiPt:/前导空白或控制字符的伪协议、data: 等其他危险 scheme。contentHtml 按 §5.2 已由服务端 sanitize,此处仅为纵深防御,但既然加了清洗就应做严:建议用成熟库(如 React 端已引入的 DOMPurify)或补齐属性/scheme 白名单。

5. Login.tsx:107-130 — passkey-only 的 MFA 用户会看到无用的验证码输入框

验证码输入框与提交按钮的渲染条件从 hasTotp 放宽为 mfaMethods.length > 0(:107,127)。当 availableMethods 仅含 passkey 时,页面会同时渲染一个 TOTP/恢复码输入框 + 提交按钮(对 passkey-only 用户无意义)和后面的 passkey 按钮(:137)。按 api.md §1.3(行 516)availableMethods 取值仅 totp/passkey,恢复码是 TOTP verify 接口 code 字段的变体(行 2254-2255),因此输入框的正确渲染条件应是"含 totp",而非"任意方法存在"。建议条件改回 hasTotp(恢复码随 totp 一并可用),passkey-only 时只显示 passkey 按钮。

@xzdmycbx
xzdmycbx self-requested a review June 18, 2026 12:13

@xzdmycbx xzdmycbx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

未发现高危/阻断级安全漏洞(access token 仅存内存、无 OAuth pendingToken 暴露、无明显 XSS sanitizer 绕过、CSRF 头与 WebAuthn 序列化均正确、契约基本一致),但存在以下 5 个中等缺陷和几个低风险漏洞,集中在安全敏感流程(改密/step-up)、后台工具与日志合规,建议修复后再合并。

中

1. 改密成功后 AuthContext token 未同步,后续 step-up 会被旧 token 覆盖

SettingsSecurity.tsx:487 改密成功仅 setClientToken(result.data.accessToken)(更新 client 内存 token),未更新 AuthContext 的 accessToken state,因此 useAuth().accessToken 仍是旧 token。随后打开 StepUpDialog(OAuth 解绑 / 删除账户等敏感操作)时,StepUpDialog.tsx:50-52 的 useEffect(() => { if (accessToken) setAccessToken(accessToken) }, [accessToken]) 会把来自 useAuth() 的旧 token 写回 client 内存,覆盖改密时设置的新 token。api.md §1.5 规定改密会递增 tokenVersion 使全部旧 access token 立即失效,被覆盖的旧 token 会触发 401(虽有 401→refresh 兜底,但属错误状态且依赖刷新链路)。建议改密成功后同步更新 AuthContext token(如调用 refreshUser / setAccessToken)。

2. Step-up 的 Passkey 手动重试按钮短路,失败后无法重试

StepUpDialog.tsx:140-142 的 handlePasskey 开头 if (!challengeId || !passkeyChallenge || passkeyProcessed.current) return;而重试按钮 :311-314 的 onClick 先 passkeyProcessed.current = true 再调 handlePasskey(),导致进入函数即命中守卫直接 return,点击无任何效果。首次 passkey 走 auto-trigger(:224-227)正常,但失败/取消后(:201 复位守卫)通过该按钮重试失效;若 passkey 是唯一 step-up 方式则用户卡住,只能关闭重开对话框。api.md §1.12 step-up 为敏感操作前置认证,其可恢复性应保证。建议 onClick 不要预设 passkeyProcessed.current = true(交由 handlePasskey 内部置位)。

3. AdminEditRequests 投票历史以契约中不存在的 id 作 React key

AdminEditRequests.tsx:216 {detail.votes.history.map(v => (<li key={v.id}>...,但 api.md §10.5(行 5692-5694)定义 history 项为 { reviewerId, vote, note, createdAt },无 id 字段;前端接口(:33)也虚假声明了 id: string。v.id 恒为 undefined,当一个申请有 ≥2 条投票(§10.6 决议恰需 2 票,是核心场景)时所有 <li> key 均为 undefined → React 重复 key 告警与 reconciliation 异常。建议 key={v.reviewerId}(api.md §15.15 UNIQUE(editRequestId, reviewerUserId),单申请内每审核员唯一)并修正类型。

4. 首页搜索"展开中清空/切词"竞态导致列表重复

Home.tsx:67-89 搜索时自动向后展开最多 5 页;展开循环仅在正常结束(gen === expandGen.current)时 setCursor(currentCursor)(:84-86)。若展开进行中(已追加若干页)用户快速清空搜索或切换搜索词,旧循环因代次失效退出但不会推进 cursor,此时 items 已含展开追加的页、而 cursor 仍停在展开前的游标。清空搜索后"加载更多"(cursor && !searchTerm)会用旧游标再次拉取已加载过的页 → 重复条目与重复 key。api.md §5.1/通用约定要求基于游标分页,重复拉旧游标违反其语义。建议代次失效退出前也同步推进/复位 cursor。

5. 后台用户搜索的邮箱(PII)被写入前端调试日志

AdminUsers.tsx:62 把 keyword(可为邮箱)拼进 /admin/users?keyword=...;client.ts 的 logRequestId 在成功响应时 console.debug('[api] ${method} ${path} requestId=...'),path 含完整 query。Vite 默认不剥离 console.debug,生产环境控制台会出现含邮箱的日志。api.md §安全与隐私基线(行 190)明确"不在 URL、Referer、日志、监控指标中泄露 token、密码、邮箱、PII"。建议日志去除 query 串或对敏感参数脱敏。


低

  • 角色门控为粗粒度(admin/reviewer),未按 §15.10 细分权限:AuthContext.tsx:68 仅 admin||reviewer;后台操作实际拆为 contribution:review/publish/hide/restore/delete、user:read、audit:read 等(api.md 行 6679 起)。Admin.tsx 的发布/隐藏/删除等按钮对具备页面访问权但不具该细粒度权限的角色仍展示,依赖服务端 403 兜底。路由级守卫(RequireAdminLayout / AdminOnlyGuard)已正确区分页面级访问,此项为按钮级加固。

  • AuthContext 初始化 /me 无 try/finally 兜底:AuthContext.tsx:72 起的 init() 若 /me 因网络异常抛出,setLoading(false) 可能不执行,导致 authLoading 卡住、受保护页与守卫长时间停在"验证中"。建议包 try/finally。

  • 登录后未消费 redirect 深链参数:RequireAdminLayout / RequireReviewerOrAdminLayout 重定向到 /login?redirect=<path>,但 Login.tsx 不读取该参数,登录后固定跳 /admin 或 /submit,原目标丢失(纯 UX)。

  • story/main.js 内联 onclick 模板拼接:story/main.js:135 在 JS 字符串上下文用 html() 转义并不严格;当前因资源 ID 契约为"前缀 + ULID"不可由用户控制,无实际 XSS,属加固项。sanitizeHtml 本体(href 白名单 + 大小写无关危险 scheme + 去控制字符)已较稳健,建议与主应用统一改用 DOMPurify。

  • 列表展示用 UTF-16 slice 截断用户内容:Admin.tsx、AdminEditRequests.tsx、AdminAuditLogs.tsx 的摘要/理由列表截断按 code unit 计,表情/代理对处可能显示半个字符(纯外观,提交校验处已用 limitByUnicode)。

lxgy1024 added 2 commits June 18, 2026 20:43
- fix(auth): sync AuthContext token after password change
- fix(step-up): passkey retry button no longer short-circuits
- fix(admin): vote history key uses reviewerId per api.md §10.5
- fix(home): prevent duplicate entries on search expand race
- fix(security): strip query from debug log paths per api.md
- fix(auth): ensure setLoading(false) runs on init error
- fix(login): consume ?redirect= deep-link parameter
- gate Admin review/vote buttons with hasPermission
- guard AdminEditRequests vote UI by CONTRIBUTION_REVIEW
- replace UTF-16 .slice with limitByUnicode on content
- expose permissions from AuthContext for component use
@xzdmycbx
xzdmycbx self-requested a review June 18, 2026 14:01

@xzdmycbx xzdmycbx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

经审核,该功能模块代码实现完备,与相关技术文档内容高度吻合,前期评审所提各项问题已逐一落实整改并完成闭环,整体质量符合本项目既定技术规范及验收标准。现予以批准,同意合并入主分支

@xzdmycbx
xzdmycbx merged commit 13fa4d5 into TransCircle:main Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants