15 KiB
15 KiB
RainWeb 优化重构实施计划(P0 / P1 / P2)
依据:数据库优化师(better-sqlite3 选型调研)、后端架构师(架构评审)、应用安全工程师(安全审计)、前端路由专项深挖 四份报告合并。 数据库路线已定:better-sqlite3 + Node 22 LTS(放弃 sql.js 与 PostgreSQL)。 前端路由:用户确认问题很大 → 按「方案 A 止血 → 方案 B 协议收编」两步走(见 P1-14 / P2-19)。 本计划仅执行顺序与细节,未开始任何代码改动。
全局前提(每阶段执行前)
- 备份:
cp -r data data.bak-<日期>(data/rainweb.db是唯一数据文件) - 每阶段一个 git commit,commit message 中文,如
P0: 删除 Web 更新链路,修复鉴权漏洞 - 无测试环境,验证方式 = curl 接口 + 浏览器刷新 +
node cli.js命令
P0 紧急止损(独立项,可并行,约半天)
P0-1 删除 Web 更新链路(消除 R6 + .git 覆盖风险)
server.js:删除 84-165 行/api/update/check+/api/update/run,保留/api/version(81-82 行)public/js/admin.js:删除 checkUpdate/runUpdate 函数及更新按钮 UI(约 692-731 行区域)cli.js:cmdUpgrade保留 git pull 分支(走本地 Gitea origin),删除非 git 仓库的 zip 下载分支(266-346 行区域)- 理由:用户即上游,zip 覆盖式更新是错误范式;
/api/update/run无鉴权=RCE 面,且 exclude 未排除.git会冲掉 git 历史 - 风险:低。注意前端引用一并删除,避免 404
- 验证:
curl /api/update/check→ 404;admin 面板打开控制台无报错;node cli.js upgrade走 git pull 分支正常
P0-2 JWT_SECRET 随机化(消除 R4)
middleware/auth.js:改为process.env.JWT_SECRET必须存在(长度 ≥32),否则启动失败;删除硬编码回退server.jsstart():首次启动若无JWT_SECRET,用crypto.randomBytes(48).toString('hex')生成并写入.env.json- 风险:低;已签发旧 token 全部失效(自部署可接受)
- 验证:删
.env.json重启 → 自动生成 secret;登录 →curl /api/auth/me200
P0-3 setup 接管漏洞(消除 R5)
routes/setup.js:POST /complete加authMiddleware, adminOnly;GET /setup/status响应移除default_password字段(只保留setup_complete)- 风险:低
- 验证:未登录
curl -X POST /api/setup/complete→ 401;登录后正常
P0-4 proxy SSRF 最小防护(消除 R3 的 SSRF 部分)
routes/proxy.js:GET /fetch加authMiddleware, adminOnly;new URL(target)后校验 hostname 非127.0.0.1/::1/localhost/10./192.168./172.16-31./169.254./fc00:/fe80:- 风险:低(本功能只服务于 admin_links 面板嵌入)
- 验证:未登录请求 → 401;
/api/proxy/fetch?url=http://127.0.0.1:3001/→ 拒绝
P0-5 邮件验证码回显与滥用(消除 R7)
routes/email.js:send-verify响应删除code回显;验证码生成改crypto.randomInt(10000000, 100000000);补回"删除 pending 后重建"的 bug(或废弃该接口由 register 内联重发)- 风险:低
- 验证:调 send-verify → 响应无 code 字段;邮件仍能收到验证码
P1 安全加固 + 数据层统一(依赖顺序:11 → 12,其余独立)
P1-6 上传白名单 + /uploads 防护头(消除 R1)
routes/upload.js:shaFileUpload内校验path.extname(...).toLowerCase()在ALLOWED_EXT = ['.png','.jpg','.jpeg','.gif','.webp','.pdf','.zip','.txt','.md']内,否则报"不支持的文件类型";图片额外校验file.mimetype以image/开头server.js:63:/uploads静态服务加X-Content-Type-Options: nosniff+Content-Security-Policy: sandbox; default-src 'none';非白名单扩展名强制Content-Disposition: attachment- 风险:低;已有历史上传文件不受影响(仅新上传受限)
- 验证:登录后上传
x.html→ 400;上传 png → 200;访问/uploads/<hash>.png响应头含 nosniff
P1-7 内容净化双端统一(消除 R2)
ssr.js:引入dompurify + jsdom(服务端),renderSSR中marked.parse结果过DOMPurify.sanitize;ssrPage中title/siteName/ogDesc全部 HTML 转义,JSON-LD 用JSON.stringify序列化public/js/render.js:引入 DOMPurify(本地化到 public/js/vendor/ 或 CDN + SRI),marked.parse结果同样过 sanitize- 风险:中(渲染路径改动,需走查博客/论坛/SEO 页)
- 验证:发帖内容含
<img src=x onerror=alert(1)>→ SSR 页与 SPA 页均不弹窗、标签被剥;正常 markdown 渲染不变
P1-8 登录限流 + 服务端验证码(消除 R8)
- 新增
express-rate-limit依赖:/api/auth/login15 分钟 10 次;/api/email/send-verify60s/次 routes/auth.js:login/register 按captcha_login/captcha_register设置服务端校验 captcha token(与 captcha.js 的 verify 存储联动,token 与 username 绑定);routes/forum.js发帖同理(captcha_forum)- 删除
verifyCaptchaToken桩函数(auth.js:39-52) - 风险:中(验证码链路前后端要同步改)
- 验证:连错密码 11 次 → 429;开启 captcha_login 后登录需先过验证码
P1-9 密码箱解锁限流 + KDF 加固(消除 M1)
routes/passwords.js:/unlock加失败计数(3 次/5 分钟,指数退避);解锁会话滑动过期替换一次性 setTimeout- PBKDF2 迭代 100k → 600k(OWASP 2023 建议);注意同步执行阻塞事件循环,可接受(单用户站)
- 风险:低
- 验证:连续输错 PIN 3 次 → 锁定提示;正确解锁正常;旧 PIN 密文在 KDF 迭代变更后需重设(set-pin 已存在)
P1-10 鉴权与信息收敛(消除 M2-M7)
middleware/auth.js:adminOnly增加db.get('SELECT role FROM users WHERE id=?')复查(M2)routes/blog.js:?all=1加authMiddleware, adminOnly;GET /posts/:id未发布仅 admin/作者可见(M3)- 错误信息收敛:
routes/forum.js:21,35、server.js:163、routes/email.js:54,93对外统一{ error: '操作失败' },详情仅 console.error(M4) routes/email.js:17、auth.js:93、profile.js:25、proxy.js:22的rejectUnauthorized:false收敛为配置项(默认关闭校验仅当 SMTP 自签时开启)(M5)public/js/passwords.js:113、forum.js:76:内联 onclick 字符串插值改data-id+addEventListener委托(M6)- S3-3 escapeHtml 引号问题:
escapeHtml不转义'/",用于属性/JS 字符串上下文可注入断链(forum.js:76、passwords.js:113、admin.js:74/402-404)——新增escapeAttr(全转义)用于属性上下文,或随 M6 的 data-id 改造一并消除;帖子标题/分类为他人可见,属存储型 XSS 面 routes/profile.js:avatar 仅接受/uploads/avatars/站内路径(M7)- 风险:中;验证:逐项 curl 测试(未登录 401/403、草稿对匿名 404、报错无堆栈详情)
P1-11 cli.js 统一数据层(ora-2 P1-1)
cli.js:删除 9-51 行重复封装(DB_PATH/getDb/dbRun/dbGet/dbAll),改为const db = require('./db');cmdStatus/cmdPassword/cmdCaptcha/cmdConfig改用db.get/db.all;各命令await getDb()data.db旧路径彻底退役(server.js 启动迁移逻辑保留,把旧文件搬进 data/)- 风险:中低;验证:备份后依次跑
node cli.js status/config/password/captcha全部成功且落库
P1-12 schema_version 迁移框架(ora-2 P1-2,依赖 P1-11)
db.js:initTables()收敛——"确保列存在"函数(幂等补列,逻辑同现有 try/catch)+PRAGMA user_version记录迁移版本;未来迁移写成{ version, up() }数组顺序执行- 风险:中;验证:旧库启动自动补列无报错;连续启动两次幂等
P1-13 deploy 脚本处置(ora-2 P1-3)
- 删除
deploy.sh+deploy.bat(指向 GitHub 旧仓库,对自托管用户无意义);README 相关段落同步清理 - 风险:低;验证:
ls确认删除;README 无残留引用
P1-14 前端路由止血(方案 A,约 0.5 天)
专项深挖结论:PJAX 只替换
<main>、目标页<script>从不执行,而 5 个 PJAX 壳页各自加载不同共享脚本子集 → 从某些页面进入时目标页依赖缺失(结构性缺陷 S1-3)。已确认 bug:S1-1 首页空白、S1-2 个人中心空白、S1-3 脚本依赖矩阵、S2-1 监听器重复累积、S2-2 论坛详情后退进 SSR 页、S2-4 滚动不恢复、S2-5 登录态/设置变化后导航不刷新、S2-6 login/register 白跳。
- A1 PJAX_PATHS 收敛:
router.js只保留 5 个有_pageConfig的页面(/、/blog.html、/forum.html、/admin.html、/passwords.html);移除login.html/register.html/profile.html(整页导航约定,根治 S1-2、S2-6) - A2 统一共享脚本集:5 个 PJAX 壳页 html 全部加载完整共享集(marked + render + captcha + music-embed + theme + api + nav + router),消除 S1-3 全部矩阵缺口
- A3 首页逻辑外部化:
public/index.html:54-96内联 HOMEPAGE 抽为public/js/homepage.js并注册进_pageConfig(修 S1-1;顺带合并 S3-4 与 blog.js 的 loadSidebar 重复) - A4
_loadScript判重:router.js:91-99加已加载检测,修 S2-1 监听器累积 - A5 popstate 分支 + 滚动:popstate 识别
e.state.forumPostId直接还原论坛详情(修 S2-2 不再 reload 进 SSR 页);navigate 时scrollTo(0,0)(修 S2-4) - A6 导航重渲染钩子:loadPage 完成后调
NAV.render?.()(修 S2-5) - 风险:低(login/register/profile 当前本就走整页兜底,无回归面);A2 可能触发 S3-2 重复定义,但各文件实现相同无碍
- 验证:passwords.html 整页进入 → PJAX 依次进博客(markdown 正常)/论坛(发帖弹验证码、详情正常)/首页;论坛详情后退回 SPA 列表无 reload;循环 PJAX 5 次 forum.js 只加载一次
P2 数据库换轨 + 工程卫生(依赖 P1-11/P1-12)
P2-15 better-sqlite3 迁移(lib-1 调研结论)
- 前置:Node 升 22 LTS(宝塔 Node 版本管理器 / nvm);README 与 Docker 示例同步(
node:20→node:22) - 依赖:
npm install better-sqlite3(v13,预编译随包,宝塔 glibc x64 免编译) db.js:initSqlJs()异步初始化 →new Database(DB_PATH)同步;run/get/all重写:run→db.prepare(sql).run(...params)+info.lastInsertRowidget→stmt.get(...params);all→stmt.all(...params)- 删除
saveDb()/db.export()(SQLite 事务原生落盘);返回签名不变,40+ 业务调用点零改动 initTables()的 CREATE TABLE IF NOT EXISTS 与幂等补列原样保留(兼容旧库)
cli.js:同套改写(P1-11 统一后只需改 db.js 一处)routes/import.js:上传文件先落临时路径再new Database(tmpPath)只读打开,exec数组结果改对象结果,用后 close- 数据迁移:零迁移——现有
data/rainweb.db是标准 SQLite 文件,直接打开 - 风险:中;验证:备份 data/ 后启动 → 管理后台 CRUD 走一遍 →
node cli.js status/password→sqlite3 data/rainweb.db "PRAGMA integrity_check;"
P2-16 死代码 + 前端重复清理
- 删除
routes/links.js(引用不存在的 links 表,挂载即崩)、public/js/main.js(无引用)、public/js/passwords.js内重复的deleteFromDetail - S3-1:
register.html:85与:92重复调用CAPTCHA.checkRequired('register'),删一行 - S3-2:
escapeHtml/closeDialog/openDialog/showSnackbar在 nav/render/blog/forum/admin/passwords/index/profile 各定义一份 → 收敛到 nav.js(或公共 util)单份定义;admin.js 同文件内 6 个函数重复定义(setNavStyle/setCardStyle/previewWallpaperUrl/removeWallpaper/loadWallpaperList/selectUploadedWallpaper/uploadWallpaper,admin.js:179/263 等)删后一份 - 风险:低;验证:全站走查控制台无 404、无重复定义报错
P2-17 版本单源 + 文档同步
VERSION文件为唯一源(server.js:81 已读它);cli.jsHELP 版本改读 VERSION;package.jsonversion 与 VERSION 同步README.md:Node 要求改>= 22 LTS;删除更新/部署章节中 GitHub 引用AGENTS.md:数据层章节重写(better-sqlite3、无 saveDb、单文件直写、cli.js 已统一)- 风险:低;验证:
node cli.js help与/api/version显示一致
P2-18 可选加固(H 系列,按需确认)
- H1:
helmet()+ 收紧 CSP(CDN 引入 marked 的 5 个页面加 SRI)——建议做 - H3:验证码
Math.random()→crypto.randomInt(auth.js:80、email.js:63、profile.js:68)——建议做,改动极小 - H9:bcrypt cost 10 → 12;注册用户名格式校验——建议做
- H2:JWT 从 localStorage 改 HttpOnly cookie——改动大(前端 api.js 全量),默认不做,列入后续
- H4:首启随机管理员密码——与 setup 向导冲突,默认不做(P0-3 已封堵接管面)
P2-19 前端路由协议收编(方案 B,约 1.5-2 天,依赖 P1-14)
方案 A 修完当前全部可复现 bug;方案 B 是防复发投资——解决"下一次加页面还会不会踩坑"。
- B1 单一注册表:
PAGES配置对象path → { pjax, title, scripts, global, init },PJAX_PATHS/_pageConfig/脚本依赖全部由它派生;命中且pjax:true才拦截,其余整页导航(无兜底分支) - B2 页面结构规范:每个 PJAX 页
<main data-page="blog">,router 读data-page查注册表,不再硬编码路径数组;共享依赖在注册表声明,_loadScript按"已加载集合"增量补齐 - B3 页面逻辑全外部化:HOMEPAGE(P1-14 A3 已抽)、profile 逻辑(profile.html:43-180)抽为独立 js;内联
<script>仅保留极少量数据初始化;统一window[global].init()协议 - B4 pushState/popstate 统一由 router 管理:页面不再自行 pushState 非标准 state;论坛/博客详情作为"子路由"由注册表定义,popstate 由 router 解析还原(根治 S2-2/S2-3)
- B5 生命周期钩子:注册表条目可选
destroy(),音乐嵌入/定时器/事件解绑在此处理(根治 S2-1 结构性累积) - 整页应用(login/register/write/embed/forum-manage/setup)标记
pjax:false不参与 PJAX,保留内联脚本 - 风险:中(popstate 语义变化与音乐嵌入销毁/重建是重点回归面;11 个 html 全动,必须全导航路径走查)
- 验证:全导航路径走查(每页进出、后退/前进、刷新直链、登录/退出、音乐嵌入显示与自动隐藏);控制台零报错
- 明确不做(方案 C):引入构建链/框架——违反无构建约定,对单人无测试项目负收益
P0-1..P0-5 (独立,并行) ≈ 半天
P1-6..P1-10 (安全加固,独立并行)
P1-11 cli统一 → P1-12 schema_version ≈ 1-2 天
P1-13 / P1-14(路由止血,独立) ≈ 0.5 天
P2-15 better-sqlite3(依赖 P1-11/12)≈ 1 天
P2-16 / P2-17 / P2-18(独立)
P2-19 路由协议收编(依赖 P1-14) ≈ 1.5-2 天
若时间有限:P0 + P1 全部完成后项目即处于"安全 + 可正常使用"状态(路由 8 个 bug 全修);P2-19 是防复发投资,可最后做或延后。
交付节奏
每个 P 阶段结束:跑通验证清单 → git commit → 可随时暂停(数据文件备份在手,sql.js→better-sqlite3 前任何时刻可回滚)。