Etherpad 隐私整改实战:移除 swagger-ui 遥测、实现 updateCheck 与 pluginCatalog 显式退出(Issue #7524)
【免费下载链接】etherpadEtherpad: A modern really-real-time collaborative document editor.项目地址: https://gitcode.com/gh_mirrors/et/etherpad
Etherpad 通过本次隐私整改(对应 Issue #7524)移除了运行时依赖树中唯一已知的第三方遥测向量(swagger-ui 自带的 Scarf 像素),并为自身仅有的两处出站请求提供了可在settings.json中显式关闭的开关。本文以设计文档 docs/superpowers/specs/2026-05-15-issue-7524-swagger-ui-telemetry-design.md 为核心骨架,结合当前仓库源码、配置模板与测试用例,完整讲解问题背景、三项交付物(RapiDoc 替换、privacy配置、PRIVACY.md)、测试验证方式与回滚策略。读完本文,你将能:理解 swagger-ui 遥测问题的来龙去脉;掌握在 Etherpad 中禁用版本检查与插件目录拉取两个出站请求的完整配置方法;并了解如何在隔离/离线环境中自建 API 文档展示与插件安装流程。
问题背景:为什么必须处理遥测
swagger-ui 的 Scarf 像素无法关闭
Etherpad 此前依赖swagger-ui-express ^5.0.1在/api-docs渲染 OpenAPI 规范。上游 npm 发行版会在安装或运行时注入一个Scarf 分析像素(analytics pixel),且该行为既无法在安装时禁用,也无法在运行时关闭(详见上游问题 swagger-api/swagger-ui#10573)。由于 Scarf 像素的加载不受 Etherpad 自身控制,它成为整个运行时依赖树中唯一已知的第三方遥测向量。
设计文档对范围做了明确界定:
- 不替换
static.etherpad.org本身,也不托管镜像; - 不审计除两个已知端点以外的遥测行为;
- 不改变
/api-docs.json(规范端点保持不变); - 管理端 OpenAPI 编辑器(Issue #7693)属于独立 PR,不在本次范围内。
Etherpad 自身仅有的两处出站请求
除了第三方依赖的遥测,Etherpad 自身代码会发起两个出站请求,二者共享同一个updateServer设置(默认https://static.etherpad.org):
| # | 触发方 | 行为 | 频率 | 是否可关闭 |
|---|---|---|---|---|
| 1 | src/node/utils/UpdateCheck.ts | 每小时GET ${updateServer}/info.json,用于管理面板的“有可用更新”提示 | 每小时 | 整改前无退出开关 |
| 2 | src/static/js/pluginfw/installer.ts | 管理端插件页加载时GET ${updateServer}/plugins.json,用于列出可安装的ep_*插件 | 页面加载时(缓存 10 分钟) | 整改前无退出开关 |
当时仓库没有任何公开文档阐明 Etherpad 对遥测的立场,这也是本次整改要一并补齐的空白。
交付物一:用 vendored RapiDoc 替换 swagger-ui-express
移除清单
本次改动从依赖与源码中彻底移除 swagger-ui:
- 从
src/package.json的 dependencies 中删除swagger-ui-express ^5.0.1; - 从
src/package.json的 devDependencies 中删除@types/swagger-ui-express ^4.1.8; - 删除
src/node/handler/RestAPI.ts中对swagger-ui-express的import {serve, setup}; - 删除
RestAPI.ts中原本的/api-docs路由注册块(app.use('/api-docs', serve)与app.get('/api-docs', setup(...)))。
新增清单
src/static/vendor/rapidoc/rapidoc-min.js:从https://unpkg.com/rapidoc@9.3.x/dist/rapidoc-min.js供应商化(vendored)而来(MIT 许可,约 370KB),作为静态资产直接提交进仓库,运行时不做任何 CDN 拉取。精确固定版本号记录在src/static/vendor/rapidoc/VERSION中。src/static/api-docs.html:极简 HTML 外壳,通过<rapi-doc>自定义元素加载规范:
<!doctype html><html><head><title>Etherpad API</title> <script type="module" src="/static/vendor/rapidoc/rapidoc-min.js"></script> </head><body> <rapi-doc spec-url="/api-docs.json" theme="light" render-style="read" show-header="false" allow-server-selection="false"></rapi-doc> </body></html>- 路由注册:
/api-docs指向api-docs.html,静态资产由/static/vendor/rapidoc/提供。最简路径是把 HTML 文件放到src/static/下,让既有的静态文件中间件直接接管;若需要显式路由,则在RestAPI.ts中与/api-docs.json处理器相邻添加即可。
保持不变的部分
/api-docs.json路由保持原样(设计文档记录于RestAPI.ts:1449-1453,当前实现位于 src/node/handler/RestAPI.ts);src/node/types/SwaggerUIResource.ts类型文件保留(仅供openapi.ts使用的 TypeScript 类型,无运行时依赖);openapi.ts:810处与 swagger 无关的注释保留。
从当前仓库看,这一替换已落地:src/static/api-docs.html已存在,且src/node/handler/RestAPI.ts中/api-docs处理器通过res.sendFile(path.join(settings.root, 'src', 'static', 'api-docs.html'))返回该页面(见 RestAPI.ts#L1440-L1442)。需要说明的是:实际提交时渲染器选用了同为 MIT 许可的Scalar(vendored 于src/static/vendor/scalar/,见 CHANGELOG.md),并在 src/static/api-docs.html 中通过withDefaultFonts: false、telemetry: false、agent: {disabled: true}、mcp: {disabled: true}以及强制系统字体栈等配置,确保页面不产生任何外部网络请求——这与设计文档“供应商化 + 零出站调用”的核心意图完全一致。
干净供应商化的验证手段
设计文档要求,在提交 vendored 文件前用 grep 检查以下特征串:
fetch(, XMLHttpRequest, sendBeacon, scarf, googletag, analytics, navigator.connection任何命中都必须被审查并确认为同源规范加载(可接受)或被移除,审查结果记录在 PR 描述中。以当前仓库的 Scalar 文件为例,grep -o -E "telemetry|analytics|scarf" src/static/vendor/scalar/standalone.js仅命中 2 处telemetry(对应页面中显式关闭的配置项),未发现fetch(、scarf或广告类特征串。
交付物二:privacy 隐私退出配置
配置结构与默认值
在src/node/utils/Settings.ts中,与既有privacyBanner并列新增privacy块:
privacy: { updateCheck: boolean, // default true pluginCatalog: boolean, // default true },两个默认值均为true,保证行为与整改前完全一致(对既有安装非破坏);运营者将其翻转为false即可分别静默对应出站调用。该类型定义已存在于 Settings.ts#L205-L208。
模板与环境变量注入
settings.json.template中已加入带注释的privacy块,且支持环境变量注入(见 settings.json.template#L456-L459):
"privacy": { "updateCheck": "${PRIVACY_UPDATE_CHECK:true}", "pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:false→true}" },实际模板内容为:
"privacy": { "updateCheck": "${PRIVACY_UPDATE_CHECK:true}", "pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}" },这一环境变量注入设计对离线/隔离部署尤其重要:CHANGELOG 指出,防火墙隔离的部署此前无法在不改动镜像内settings.json的情况下禁用出站调用,现在可通过PRIVACY_UPDATE_CHECK、PRIVACY_PLUGIN_CATALOG、UPDATES_TIER(off= 零调用)、UPDATE_SERVER等环境变量直接控制(见 CHANGELOG.md)。
UpdateCheck.ts:版本检查的静默化
src/node/utils/UpdateCheck.ts 中对应两处关键改动:
check():当settings.privacy.updateCheck === false时提前返回,只记录一次日志Update check disabled by privacy.updateCheck=false (see PRIVACY.md),不发请求、不安排重试:
export const check = () => { if (!settings.privacy.updateCheck) { if (!loggedDisabled) { console.info('Update check disabled by privacy.updateCheck=false (see PRIVACY.md)'); loggedDisabled = true; } return; } needsUpdate((needsUpdate: boolean) => { ... }).then(()=>{}); };getLatestVersion():禁用时返回undefined。现有调用方 src/node/hooks/express/adminsettings.ts#L163(latestVersion: getLatestVersion())本就容忍undefined,管理面板会直接省略“有可用更新”那一行:
export const getLatestVersion = () => { if (!settings.privacy.updateCheck) return undefined; needsUpdate().catch(); return infos?.latestVersion; };底层实现细节:loadEtherpadInformations()会以updateInterval = 60 * 60 * 1000(1 小时)为间隔缓存结果,携带User-Agent: Etherpad/<version>请求${updateServer}/info.json;只有关闭开关时这一链路才会被完全短路。
installer.ts:插件目录的按需门禁
src/static/js/pluginfw/installer.ts 中:
getAvailablePlugins()入口先调用门禁函数,禁用时抛出带标签的错误:
export const getAvailablePlugins = async (maxCacheAge: number | false) => { assertPluginCatalogEnabled(); ... const pluginsLoaded = await fetch(`${settings.updateServer}/plugins.json`, {headers}); ... };- 门禁实现位于 src/static/js/pluginfw/pluginCatalogGuard.ts:
export const assertPluginCatalogEnabled = () => { if (!settings.privacy.pluginCatalog) { throw new Error( 'Plugin catalog disabled by privacy.pluginCatalog=false (see PRIVACY.md)' ); } };- 管理端消费者 src/node/hooks/express/adminplugins.ts 捕获该特定错误并渲染回退面板:“Plugin catalog is disabled. Enter a plugin name to install manually.”,提供自由文本安装输入框。只有“浏览目录”被门禁,
install(pluginName)本身仍然可用。
从当前源码可见该门禁已在 socket 层逐事件落实:getInstalled、checkUpdates、getAvailable、search四个事件均先检查settings.privacy.pluginCatalog,禁用时跳过目录相关请求(getInstalled中updatable保持未设置,UI 不显示“可更新”徽标)并发出results:catalogDisabled事件(见 adminplugins.ts#L48-L117)。
bin/plugins/stalePlugins.ts:开发工具的联动
bin/plugins/stalePlugins.ts原先硬编码https://static.etherpad.org/plugins.full.json,本次改写为读取settings.updateServer并尊重settings.privacy.pluginCatalog;禁用时记录日志并以退出码 0 结束(这是开发工具,失败没有意义)。当前实现已在入口处执行同样检查(见 bin/plugins/stalePlugins.ts#L10-L16)。
交付物三:PRIVACY.md 与 README 链接
新增根目录级 PRIVACY.md,内容简短且事实性,核心结构如下:
- What this document is:完整、最新地列出 Etherpad 自身代码对第三方发起的每一次网络调用,以及如何逐一关闭;
- TL;DR:Etherpad 内置两个指向 etherpad.org 的出站调用,均可通过单个配置值分别禁用;运行时无分析、无用例上报、无第三方 SDK;
- Outbound calls:以表格形式给出两个出站调用的 URL、频率、载荷、目的、禁用方式与源码位置:
- 版本检查:
https://static.etherpad.org/info.json(可用updateServer覆盖)、每小时、仅 GET(User-Agent: Etherpad/<version>)、禁用方式privacy.updateCheck: false、源码 src/node/utils/UpdateCheck.ts; - 插件目录:
https://static.etherpad.org/plugins.json(可用updateServer覆盖)、管理插件页加载时(缓存 10 分钟)、仅 GET、禁用方式privacy.pluginCatalog: false(按名手动安装仍可用)、源码 src/static/js/pluginfw/installer.ts;
- 版本检查:
- What we removed:swagger-ui-express 因上游注入不可关闭的 Scarf 像素而被移除;
/api-docs改由 vendored 的无出站调用渲染器提供(当前仓库为 Scalar,MIT); - What we will not add:不引入使用分析/遥测 SDK、不经明确同意就上报的崩溃报告器、运行时第三方 CDN 依赖、安装或运行时回传的依赖;
- Plugins:第三方插件不在该保证范围内(插件运行在你的 Etherpad 进程中并拥有完整权限,安装任何插件前都应审计);
- Reporting:发现文档未列出的出站调用,请以
privacy标签提交 Issue。
配套改动:
- README.md 顶部简介下方加入一行:“Privacy: Etherpad makes two opt-out network calls and ships no third-party telemetry. See PRIVACY.md.”(当前仓库中该声明已体现于 README.md#L13);
- CHANGELOG.md 新版本条目记录:移除
swagger-ui-express(第三方遥测);/api-docs改由 vendored 渲染器提供;新增privacy.updateCheck与privacy.pluginCatalog退出开关(见 CHANGELOG.md#L265-L269)。
测试与验证
后端测试(vitest)
设计文档规划的测试用例,在当前仓库的 src/tests/backend/specs/settings.ts 中已落地为三组断言:
- 默认值测试:未设置环境变量时,
settings.json.template与 docker 配置解析出的privacy.updateCheck、privacy.pluginCatalog均为true; - 离线注入测试:设置
PRIVACY_UPDATE_CHECK=false、PRIVACY_PLUGIN_CATALOG=false、UPDATES_TIER=off后,解析结果为真实布尔值false(而非字符串"false"),且updates.tier变为off; - 覆盖测试:
UPDATES_SOURCE=gitlab、UPDATE_SERVER=https://mirror.internal/ep_infos等环境变量被正确解析为数值与布尔类型。
文档同时规划的UpdateCheck.test.ts(check()在禁用时不发起 fetch)与installer.test.ts(getAvailablePlugins()抛出带标签的禁用错误)也应在合并前补齐。
手工冒烟(合并前,端口 9003)
- 启动开发服务器,打开
/api-docs——确认渲染器正常展示规范,且 DevTools Network 面板显示零个第三方主机; - 设置
privacy.updateCheck: false后重启——确认不再请求static.etherpad.org/info.json,管理面板“有可用更新”一行消失; - 设置
privacy.pluginCatalog: false后打开管理插件页——确认按名手动安装的回退面板渲染,且ep_align可按名安装成功。
既有 e2e 与依赖卫生
- 运行管理页 Playwright 套件;任何依赖 swagger-ui 特定 DOM 的测试需改为新渲染器选择器或删除;
pnpm install干净;grep -ri "swagger" src/ --exclude-dir=node_modules应只命中无关注释(openapi.ts:810)与保留的类型文件SwaggerUIResource.ts;grep -E "fetch\(|XMLHttpRequest|sendBeacon|scarf|google" src/static/vendor/<renderer>/审查结果记录在 PR 描述中。
发布、回滚与风险
发布流程
- 分支:
feature/7524-drop-swagger-ui-telemetry(基于develop); - 单个 PR 关闭 #7524;
- 推送后等待约 20 秒,运行
gh pr checks,在推进前内联修复 CI 失败; - 内联处理全部 Qodo 评审意见。
回滚策略
所有改动要么是纯增量(privacy块,两个默认值均为true),要么是一一对应替换(swagger-ui-express→ vendored 渲染器,URL 表面不变)。回滚合并即可干净地恢复原有行为。
风险清单
- 前置代理
/api-docs的运营者:URL 未变,透明; - 抓取
/api-docs.json的 API 消费者:完全不受影响; - 依赖 swagger-ui 特定 DOM 的自定义管理页:可能性低(仅核心代码),会在 CI 中暴露;
- 新渲染器上游未来加入遥测:通过固定版本供应商化 + 每次升级重新 grep缓解。
实战速查:如何在自己的部署中关闭出站调用
场景一:源码安装(settings.json)——在privacy块中显式写入:
{ "privacy": { "updateCheck": false, "pluginCatalog": false } }场景二:Docker / 环境变量注入——直接使用模板中已接线的环境变量:
export PRIVACY_UPDATE_CHECK=false export PRIVACY_PLUGIN_CATALOG=false export UPDATES_TIER=off场景三:完全离线内网——将updateServer指向内网镜像,或按上述方式关闭全部出站调用;管理面板的“有可用更新”提示与插件目录浏览将自动消失,但按名安装插件(CLI/管理端输入框)仍然可用。
小结
本次整改以“单一 PR 关闭 #7524”的方式,达成了三层目标:依赖层移除了唯一已知的第三方遥测向量;行为层为两个自身出站请求提供显式、文档化的退出开关(默认保持true,零破坏);文档层新增 PRIVACY.md 并给出“绝不添加”清单,明确 Etherpad 对遥测的公开立场。对于强调隐私合规或运行在隔离网络中的部署,privacy.updateCheck与privacy.pluginCatalog两个配置项配合环境变量注入,提供了最小改动、完全可审计的出站控制方案。
【免费下载链接】etherpadEtherpad: A modern really-real-time collaborative document editor.项目地址: https://gitcode.com/gh_mirrors/et/etherpad
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考