Skip to content

fix(spa): 注入站点设置到 index.html,修复自定义头部/CSS/JS 不生效 - #52

Open
PIKACHUIM wants to merge 1 commit into
mainfrom
fix/customize-html-injection
Open

PIKACHUIM wants to merge 1 commit into
mainfrom
fix/customize-html-injection

Conversation

@PIKACHUIM

Copy link
Copy Markdown
Member

fix(spa): 修复系统全局设置中的自定义头部 / CSS / JS 片段不生效

摘要

系统设置里的 自定义头部(customize_head)自定义内容(customize_body) —— 也就是用户用来注入自定义 CSS 与 JS 片段的地方 —— 在 TSWorker 后端上完全不生效。本 PR 补齐缺失的服务端 HTML 注入环节,并顺带对齐 favicon / logo / 站点标题 / 主题色这四项同源设置。

分支

  • 源分支:fix/customize-html-injection
  • 目标分支:main
  • 仓库:OpenList-TSWorker

问题

复现

  1. 管理员登录 → 系统设置 → 全局设置
  2. 在「自定义头部」填入 <style>body{background:#000}</style>,或「自定义内容」填入 <script>console.log('hi')</script>
  3. 保存 → 强刷首页
  4. 期望:背景变黑 / 控制台输出 hi
  5. 实际:什么都没发生

根因

前端产物 dist/index.html 里保留了字面量占位符,等待服务端做字符串替换:

<head>
    <!-- customize head -->
    ...
    <link rel="shortcut icon" ... href="https://res.oplist.org/logo/logo.svg" />
    <title>Loading...</title>
    <script>
      window.OPENLIST_CONFIG = { main_color: undefined }
    </script>
</head>
<body>
    <div id="root"></div>
    <!-- customize body -->
</body>

上游 Go 版在 server/static/static.goUpdateIndex() 里完成了这层替换。TSWorker 完全没有这个环节

src/backend/index.ts(修复前)对 HTML 入口是直接透传的:

// ASSETS 分支
if (url.pathname === "/" || url.pathname === "/index.html") {
  const headers = new Headers(res.headers)
  headers.set("Cache-Control", "no-cache, must-revalidate")
  return new Response(res.body, { status: res.status, headers })  // ← 原始 HTML,占位符原样返回
}
...
// 内联 SPA 分支
if (spaFallbackHtml && ...) {
  return c.body(spaFallbackHtml, 200, { ... })                     // ← 同样原始 HTML
}

因此两条返回路径都把带占位符的 HTML 交给了浏览器。浏览器把 <!-- customize body --> 当注释忽略 —— 表现为「设置保存成功但页面无任何变化」。

补充证据:对前端主 bundle dist/assets/index-DWvZbU_h.js(1.35 MB)全文扫描,customize 出现 0 次,insertAdjacentHTML 0 次。前端也没有运行时注入实现,所以这不是「前端少个 effect」,而是整条链路缺环。

影响范围

设置项 修复前
customize_head 不生效
customize_body 不生效
site_title 不生效(标题恒为 Loading...
favicon 不生效
logo 不生效
main_color 不生效

注意:/api/public/settings 一直正确返回这些字段(有既有测试覆盖),所以问题具有迷惑性——接口层看起来完全正常。

方案

新增 src/backend/server/index-html.ts,实现与 Go 版 UpdateIndex() 语义一致的服务端替换,并在 index.ts 的两条 HTML 返回路径上接入。

关键设计

1. 与 Go 版语义严格对齐,避免两套后端行为分叉:

占位符 设置键 说明
<!-- customize head --> customize_head 原样注入
<!-- customize body --> customize_body 原样注入
https://res.oplist.org/logo/logo.svg favicon
https://res.oplist.org/logo/logo.png logo 只取第一行(对齐 Go 的 strings.Split(...)[0]
<title>Loading...</title> site_title
main_color: undefined main_color 包成 main_color: '<value>'

2. 用 split().join() 而非 String.replace()

replace 会把替换串里的 $&$1$' 当特殊模式展开。管理员的自定义 JS 里出现 $& 完全可能(压缩器产物常见),会导致注入结果被静默破坏。已加测试锁定。

3. main_color 做引号/反斜杠转义

Go 版是 fmt.Sprintf("main_color: '%s'"),值里含 ' 会直接让内联脚本语法错误、整站白屏。这里补上转义(不与 Go 的这个缺陷对齐)。

4. 缓存 + 写入即失效

app.all("*") 是整站兜底路由,每次 SPA 导航都会命中;每请求读 DB + 6 组字符串替换在 ESA / EdgeOne 上代价过高。因此:

  • WeakMap<env, string> 缓存注入结果(与 db.ts 既有缓存策略一致,按 env 隔离避免多 isolate 串扰)
  • db.ts 新增 onDbWrite() 写入通知钩子,saveDb() 触发 → index-html.ts 注册的监听器立刻清缓存

之所以用「注册回调」而不是直接 importindex-html.ts 需要 getDb(),而 getDb() 定义在 db.ts,直接互相 import 会形成循环依赖——在 EdgeOne/ESA 的 CJS 产物里容易产生「模块只初始化了一半」的难查运行时问题。反向暴露注册点可保持依赖单向。

把失效挂在 saveDb() 上,是因为所有设置写入(/admin/setting/save/admin/setting/default/admin/setting/deleteupdateSettingValue 等)最终都汇聚到这里,覆盖全部路径且不会漏掉未来新增的写入入口。

5. 失败降级,绝不 500

取模板失败、读 DB 失败都回退到「原始未注入的 HTML」。HTML 入口 500 会让用户连错误页都看不到,代价远大于设置未生效。

覆盖面

修复同时覆盖三个部署目标(都经由 app.all("*") 这一处):

路径 场景
env.ASSETS + //index.html Cloudflare Workers 首页
env.ASSETS + SPA fallback Cloudflare Workers 深链刷新(/login/@manage/*
内联 spaFallbackHtml EdgeOne(api/_makers.ts)、阿里云 ESA(esa-entry.ts

修复前 SPA fallback 分支连 Content-Type 都没设,现在已经统一收敛到 indexHtmlResponse(),一并修正。

变更清单

文件 变更
src/backend/server/index-html.ts 新增:占位符常量、applyIndexHtmlSettings()buildIndexHtml()、缓存与失效
src/backend/server/index-html.test.ts 新增:10 个用例(8 单测 + 2 集成)
src/backend/index.ts 接入 buildIndexHtml();新增 readIndexHtmlTemplate() / indexHtmlResponse();SPA fallback 补 Content-Type
src/backend/internal/model/db.ts 新增 onDbWrite() / notifyDbWrite(),在 saveDb() 内触发

前端(OpenList-Frontend无需改动(对齐 Go 版:纯服务端注入)。

安全说明

customize_head / customize_body管理员配置的任意 HTML/JS,注入后必然可在页面执行脚本。这是上游 OpenList 的既有设计(Go 版同样如此),属于「管理员 = 可信主体」模型。

本 PR 不放松任何鉴权:只有已通过管理员接口(需 admin token)写入的值才会出现在这里。请勿在后续评审中把它当作 XSS 漏洞要求转义——那会直接破坏自定义 CSS/JS 功能本身。

测试

新增用例(10/10 通过)

单测:

  1. customize_head / customize_body 片段被注入到正确位置(head / body 各自就位)
  2. 站点标题 / favicon / logo / main_color 一并注入(与 Go 版对齐,logo 只取首行)
  3. 未配置的设置不清空占位符(避免图标裂开、标题变空)
  4. 自定义 JS 中的 $& / $1 不被当作替换模式解释
  5. main_color 引号 / 反斜杠转义
  6. buildIndexHtml 缓存命中与 saveDb 失效
  7. 显式失效后重建
  8. 模板缺占位符时原样返回(不抛错)

集成(直接打真实 Hono app,锁死「路由确实调用了注入」):
9. EdgeOne(无 ASSETS)内联 SPA 壳注入
10. Cloudflare(有 ASSETS)首页 + 深链刷新都注入

第 9、10 条是必要的:本次 bug 的本质就是「替换函数不存在/未被调用」,只测替换函数无法防回归。

验证结果

  • 新增测试:10 pass / 0 fail
  • 全量 test:server:45 pass / 3 fail —— 这 3 个失败已在干净的 main 上复现,为既有问题,与本次改动无关(Security(F-11) ×2、CAS codec ×1)
  • npm run linttsc --noEmit):通过
  • npm run build:edge通过;已确认注入逻辑进入 cloud-functions/[[default]].jsdist-server/api/[...route].js

npm run build 中的前端拉取步骤在本机因 IDE 沙箱的 safe-delete 守卫拦截批量删除 KaTeX 字体而失败,与本次改动无关;后端构建(build:edge)独立通过。

部署须知

改动源码后必须重新构建,否则线上仍是旧代码:

node scripts/build-edge.mjs

生成 cloud-functions/[[default]].jsdist-server/api/[...route].js

自查清单

  • 定位到根因(缺服务端注入环节),而非只改现象
  • 与 Go 版语义对齐,避免双后端行为分叉
  • 三条返回路径全覆盖(CF 首页 / CF 深链 / EdgeOne-ESA 内联)
  • 设置写入后缓存立即失效
  • 异常降级不 500
  • 单测 + 集成测试,防回归
  • tsc --noEmit 通过
  • 确认既有测试失败与本次改动无关(已在 main 上复现)
  • 前端改动(不需要)

根因:TSWorker 缺少服务端 HTML 占位符替换环节。前端产物 dist/index.html 里保留了
字面量占位符,需要服务端做替换;上游 Go 版在 static.go 的 UpdateIndex() 里完成了
这件事,而 TSWorker 是原样透传给浏览器,占位符被当成注释忽略。对前端主 bundle
全文扫描,customize 出现 0 次,说明前端也没有运行时注入实现,因此整条链路缺环。

缺失的占位符(对应设置键):
- <!-- customize head -->  -> customize_head
- <!-- customize body -->  -> customize_body
- https://res.oplist.org/logo/logo.svg -> favicon
- https://res.oplist.org/logo/logo.png -> logo
- <title>Loading...</title> -> site_title
- main_color: undefined -> main_color

改动:
- 新增 server/index-html.ts:applyIndexHtmlSettings() 按 Go 版语义替换上述占位符;
  buildIndexHtml() 带 WeakMap 缓存,避免兜底热路径每请求读 DB
- index.ts:ASSETS 首页、ASSETS 深链 fallback、内联 SPA 壳三条路径统一接入注入,
  并把 Content-Type / Cache-Control 收敛到 indexHtmlResponse()
- db.ts:新增 onDbWrite() 写入通知钩子(用注册回调避免与 index-html.ts 形成循环依赖),
  在 saveDb() 内触发缓存失效,保证改完设置立即生效
- 新增 10 个测试(8 单测 + 2 集成,集成测试直接打真实 Hono app)

顺带修正:
- 用 split().join() 替代 String.replace(),避免自定义 JS 中的 $& / $1 被当作替换模式展开
- main_color 补引号与反斜杠转义,避免内联脚本语法错误导致白屏
- SPA fallback 分支此前未设置 Content-Type

安全说明:customize_head / customize_body 是管理员配置的任意 HTML/JS,注入后可在页面
执行脚本,这是上游 OpenList 的既有设计(管理员=可信主体)。本改动未放松任何鉴权。
@PIKACHUIM PIKACHUIM self-assigned this Sep 14, 2026
@PIKACHUIM PIKACHUIM added the bug Something isn't working label Sep 14, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-work 782505f Sep 14 2026, 06:31 AM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-tsworkers 782505f Sep 14 2026, 06:31 AM

@pikachuren pikachuren 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.

🙏 感谢 @PIKACHUIM 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 Claude Opus 5 模型进行分析。
⚠️ AI 分析结果仅供参考,可能存在误判或遗漏。如您发现任何问题或有不同意见,欢迎随时提出讨论和纠正。
⚠️ 重要提醒:即使 AI 评审认为代码质量良好且建议合并,最终是否合并仍需由项目维护者进行人工判定。项目维护者会综合考虑代码质量、项目规划、技术方向、团队资源等多方面因素做出是否合并的决策。

🎯 结论

✅ Approve — 关键功能修复完善,与 Go 版语义对齐,安全说明清晰,测试覆盖充分

📖 概要

fix(spa): 修复系统全局设置中的自定义头部/CSS/JS 片段不生效 · 补齐缺失的服务端 HTML 注入环节
核心改动:新增 index-html.ts 实现占位符替换、接入 index.ts 两条返回路径、添加 DB 写入通知钩子

🧭 整体方案

新增 applyIndexHtmlSettings() 与 Go 版 UpdateIndex() 语义对齐,使用 split/join 避免特殊模式误解;在 index.ts 的 ASSETS 直出与 SPA fallback 两条路径接入注入;引入 onDbWrite() 钩子在 saveDb() 时失效缓存,保证设置改动立刻生效。方案完善,解决了「设置保存成功但不生效」的困惑用户体验。

📊 变更统计

4 个文件(+491 / -25 行) | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐

🚨 关键问题

无重大问题

📂 逐文件分析

src/backend/server/index-html.ts(新增,核心实现)

改动意图:实现与 Go 版 UpdateIndex() 一致的服务端 HTML 注入
代码逻辑

  1. applyIndexHtmlSettings() 逐项替换 6 个占位符(favicon/logo/site_title/main_color/customize_head/customize_body)
  2. buildIndexHtml() 按 env 缓存注入结果,失败降级返回原始 HTML
  3. invalidateIndexHtmlCache() 清除 env 对应的缓存条目
  4. 模块顶层调用 onDbWrite() 挂接 saveDb() 的写入通知

问题分析:✅ 设计精准、细节完善:

  • ✅ 用 split/join 避免 $&/$1 被当特殊模式解释(管理员自定义 JS 可能包含这些)
  • ✅ main_color 做引号/反斜杠转义防止内联脚本语法错误
  • ✅ logo 取第一行与 Go 版 strings.Split()[0] 对齐
  • ✅ 空值跳过替换避免清空占位符(favicon/logo 裂开、标题变空)
  • ✅ WeakMap 缓存按 env 隔离,避免多 isolate 串扰
  • ✅ 失败降级到原始 HTML(不 500,页面可用)
  • ✅ 模块顶层挂钩保证 saveDb() 前缓存已就位

安全说明:✅ 合理透彻:

  • customize_head/customize_body 是管理员后台配置,属「管理员=可信主体」模型
  • 仅通过 /admin/setting/save(需 admin token)才能写入
  • 不额外转义避免破坏自定义 CSS/JS 功能
  • 符合 Go 版既有行为,无新增风险

src/backend/server/index-html.test.ts(新增测试)

改动意图:锁定占位符替换行为
代码逻辑

  • 单测 8 条:customize_head/body 注入、favicon/logo/title/mainColor 对齐、特殊字符处理、引号转义、缓存命中/失效
  • 集成测试 2 条:EdgeOne 无 ASSETS 内联壳注入、Cloudflare 有 ASSETS 首页+深链都注入

问题分析

  • ✅ 单测覆盖替换逻辑的正确性
  • ✅ $&/$1 特殊模式用例是真实的回归防护(修复前会失败)
  • ✅ 集成测试直接打真实 app 验证路由确实调用了注入(本次 bug 的根本)
  • ✅ 两条集成测试覆盖三个部署目标(CF/EdgeOne/ESA)

src/backend/index.ts

改动意图:接入占位符替换、补齐缺失的 Content-Type
代码逻辑

  1. 新增 readIndexHtmlTemplate() 从 ASSETS 读取模板
  2. 新增 indexHtmlResponse() 统一 HTML 响应(Content-Type + Cache-Control)
  3. //index.html 路径:读模板 → 注入 → 响应
  4. SPA fallback 路径:同样先注入再响应
  5. 内联 SPA 壳:通过 buildIndexHtml() 一并处理

问题分析

  • ✅ 三条返回路径(ASSETS 首页、ASSETS fallback、内联壳)都接入注入
  • ✅ Content-Type 补齐避免浏览器错误解析
  • ✅ Cache-Control 保留 no-cache 防止版本切换时旧 HTML 缓存
  • ✅ 失败降级到原始 ASSETS 直出,不影响可用性
  • ✅ readIndexHtmlTemplate() 失败返回 null 而非抛错,体现 fail-safe 原则

src/backend/internal/model/db.ts

改动意图:新增 DB 写入通知钩子
代码逻辑

  1. onDbWrite() 注册监听器(供 index-html.ts 挂接)
  2. notifyDbWrite() 触发所有监听器(在 saveDb() 内存更新后调用)
  3. 监听器异常不影响写入本身的返回值

问题分析

  • ✅ 用注册钩子而非直接 import 避免循环依赖(index-html.ts 需要 getDb())
  • ✅ 反向暴露注册点保持依赖单向性
  • ✅ 在「内存已更新、落盘之前」触发失效确保一致性
  • ✅ 异常捕获避免监听器失败拖累业务逻辑

✅ 待处理清单

无待处理项

🎯 结论:✅ Approve — 功能修复完善、与 Go 版对齐、安全设计清晰、测试全面、可直接合并

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

全局设置中的‘自定义内容’和‘自定义头部’配置后不生效

2 participants