post: two reader/gateway posts — the upstream rewrite, and the front-door pattern (EN+ZH) + og/banner images
Deploy / build (push) Successful in 22s

1) the-upstream-was-deleted-then-came-back-rewritten (devops, pubDate 2026-10-06)
   docker pull hectorqin/reader -> 404; the repo was re-initialised 2026-09-16 and now
   carries 233 commits, a TypeScript/Node rewrite, port 5888, SQLite /data, config moved
   into the admin UI, and an image on cnb.cool with no tags or releases. Covers telling
   dead from rewriting, the migration boundary, and keeping the front door outside the
   app image. Facts re-verified live 2026-10-06.

2) putting-a-front-door-on-an-app-you-cant-modify (devops, pubDate 2025-06-24 backdated
   into the empty 2025-06 window, updatedDate 2026-10-06 so lastmod stays honest)
   The reader-gateway pattern: a second nginx container owning the public port, the
   request-time resolver, why a proxy body rewrite cannot touch a client-rendered SPA,
   the gate-bounce injection with its pass token, the app's own CSS hook for branding,
   and the silent-failure check to run after every app upgrade.

Both: EN + ZH twins, TERMINALS/BANNERS entries appended via append_generator_entries.py
(additive, deletions 0, node --check OK), og/banner generated and measured PASS
(og rows=1 overflow=0 missingHash=0; banner 8 rows, delta 2, overflow 0).
This commit is contained in:
hoelee committed 2026-10-06 17:19:46 +08:00
1 parent 67b844e3a0
commit 378d55c3c8
10 files changed
+732

No files matched your search

@@ -0,0 +1,175 @@
---
title: "给自己改不了源码的应用装一扇前门"
description: "我自托管阅读服务的门面,是一个中文登录框;而应用本身是客户端渲染的 SPA,我改不动。于是我在它前面加了一个容器:nginx、一个双语页面,和三行注入。"
pubDate: 2025-06-24
updatedDate: 2026-10-06
category: devops
tags: ["nginx", "docker", "self-hosting", "seo", "branding"]
ogImage: "/og/putting-a-front-door-on-an-app-you-cant-modify.png"
banner: "/banners/putting-a-front-door-on-an-app-you-cant-modify.png"
draft: false
---
我的阅读服务,访客打开看到的第一样东西是一个中文登录框。没有说明这个站是什么,没有邀请码说明,没有品牌,链接分享出去也没有社交卡片,搜索引擎也没有任何值得收录的东西。就是一个登录弹窗,上面一首诗。
这个服务是我给家人和几位付费读者跑的,所以「新读者第一眼看到什么」不是装饰问题,它就是产品本身。而我改不了它——它背后是别人的代码:一个 hash 路由的 Vue 单页应用,界面不归我管,版本按别人的节奏发布。
**那么问题来了:怎么给一个自己改不了的自托管应用套上自己的首页、品牌和语言处理,既不 fork 它,也不做一个下次镜像更新就碎掉的包装层?**
## 先说为什么那些「显然的做法」都不行
**直接改应用的 HTML。** 没东西可改。这一步很多人都跳过,但它决定了后面整套方案:
```bash
curl -s https://book.example.com/index.html | wc -c
# 5879 — and the body is <div id="app"></div>
```
这个页面只是一个外壳,所有按钮、文字和弹窗都是之后由 JavaScript 画出来的。所以既没有标记可以改进,也没有东西可以重排。
**在代理层重写响应。** 这个最诱人——Traefik 或 nginx 的响应体重写中间件。它失败的理由和上面一样:body 重写只能重写**body 里存在的东西**,而 body 里只有一个空 div 和一个 script 标签。这条值得当成一条通用规则记住,在任何时候伸手去用反向代理的 body 重写功能之前先做一次:
> 用 `curl` 拿到原始响应,grep 一下你想干掉的那个字符串。如果它不在你收到的字节里,那么内容是客户端渲染的,body 重写就是错的工具——你需要的是 CSS 或 JS,并且注入到应用会加载的地方。
**改压缩后的 bundle。** 技术上可行,因为编译产物我看得到。但每次镜像更新都会带来重新构建(有时还重新压缩)的 bundle,于是这就变成一份对着别人的代码库长期维护的活。而且它的失败方式最糟糕:静默、在生产环境、在某个不相干的升级之后。
**把我自己的页面塞进应用的镜像里。** 这样我的落地页、SEO 标签和社交卡片就绑死在别人的发布节奏上,上游一个坏构建能把我整扇前门一起带走。我最不想做的事,就是让我的品牌依赖他们的 Dockerfile。
所以:不放进应用里,不重写应用,也不 fork。放在它**前面**。
## 方案:多一个容器,让它持有对外端口
这条 stack 里有两个服务。应用不对外暴露任何公开端口;一个极小的 `nginx:alpine` 网关持有它,并决定每个请求是什么:
```yaml
services:
reader:
# the app: loopback-only port kept for debugging, never public
# (127.0.0.1:7778:8080)
networks: [bridge_hoelee]
reader-gateway:
image: nginx:alpine
ports:
- "7777:80" # the only public door
networks: [bridge_hoelee]
volumes:
- /volume1/docker/reader/gateway/conf/default.conf:/etc/nginx/conf.d/default.conf:ro
- /volume1/docker/reader/gateway/html:/usr/share/nginx/html:ro
```
nginx 那一侧短到可以整段读完。有意思的决定不在路由本身,而在路由周围那四行:
```nginx
# resolve the app at request time, not at startup
resolver 127.0.0.11 valid=10s ipv6=off;
set $reader_upstream http://reader:8080;
location = / { # the front page: exact match, bare root only
root /usr/share/nginx/html;
try_files /index.html =404;
add_header Cache-Control "no-store" always;
}
location / { # everything else: the app, untouched
proxy_pass $reader_upstream;
client_max_body_size 1024m; # uploads still work through the middle box
}
```
**在请求时解析上游,而不是启动时。** 如果写成字面量 `proxy_pass http://reader:8080`,nginx 只在启动那一刻解析这个名字一次,解析不到就拒绝启动——于是应用每次重启或被替换,我的前门就跟着下线。改用变量加 Docker 内嵌 DNS(`127.0.0.11`),就把解析推迟到每个请求。应用停着的时候网关照样能启动,这件事在你第一次重建应用时很重要,在你半夜排查时也很重要。
**把应用自己的端口留给调试,而且一次改完。** 应用仍然在 loopback 上发布 `127.0.0.1:7778:8080`:从宿主机通过 SSH 能连,公网看不见。做这次切换时,旧容器必须和网关在**同一次** stack 更新里交接那个端口;拆成两次部署会绑定失败。
**别让中间那台机器变成新的限制。** 网关设置了 `client_max_body_size 1024m`,因为这个应用允许上传大体积的电子书。在一个有文件上传的应用前面加代理,是引入「只在某天有人上传大文件时才出现」的 bug 的经典方式。
**不同路由用不同缓存时长。** 首页是 `no-store`(我直接在磁盘上编辑它,希望刷新即生效),社交图缓存一天,注入脚本缓存五分钟。各一行。
## 页面本身
它是一个自包含的 HTML 文件:没有网络字体、没有 CDN、没有构建步骤。这不是为了极简而极简——它意味着这扇前门没有任何第三方依赖会变慢、被墙或停止服务,而且断网时它还能从缓存里渲染出来。
里面有两个决定值得照抄:
**两种语言同时存在于 DOM 里。** 每一句文案都出现两次——一个 `.zh` span 和一个 `.en` span——由 `<html>` 上的一个属性通过 CSS 决定显示哪一套。默认跟随浏览器语言,用 `?lang=en` / `?lang=zh` 可以覆盖,选择记在 `localStorage`。因为两种语言都在 HTML 里,搜索引擎会两种都收录,这是 JavaScript 切换器做不到的。
**邀请码故意不放在页面上。** 页面明确写着邀请码印在实体邀请卡上,而不是把它显示出来。一个公开的落地页如果泄露注册码,就等于把一个只发邀请的服务变成开放注册。
## 注入进应用的三行,以及它们为什么是重点
一个放在应用前面的落地页,如果不把接缝藏掉,看起来仍然是两个产品。应用所在的 location 块里有三条 `sub_filter` 规则负责这件事——它们在应用的外壳经过时打补丁(那 5,879 字节,是客户端渲染应用里唯一真的存在于字节中的部分):
```nginx
location = /index.html {
proxy_pass $reader_upstream;
proxy_set_header Accept-Encoding ""; # sub_filter cannot rewrite a compressed body
# 1. the app declares the wrong language, which disables browser translation
sub_filter '<html lang="en">' '<html lang="zh-CN">';
# 2. reload/bookmark/new tab -> back to the front door; the enter button sets the pass
sub_filter '</head>' '<script>(function(){try{if(!navigator.onLine)return;
if(sessionStorage.getItem("gatePass")==="1"){sessionStorage.removeItem("gatePass");return;}
location.replace("/"+(location.hash||""));}catch(e){}})();</script></head>';
}
```
第二条才是真正有意思的。刷新、用书签打开、或者新开标签页,都应该再回到首页——我希望应用的地址表现得像一个产品,而不是把陌生人丢进应用内部、还没有出口。机制是一个 `sessionStorage` 标记:没有标记就「弹回前门」,进入按钮会先写上标记,而脚本会**消费**掉它(删掉),这样下一次刷新又会回弹。少了「消费」这一步,就会在页面和应用之间无限来回跳,那个标签页按逻辑就关不掉了。`navigator.onLine` 的判断让离线场景放行,所以应用装成 PWA 断网时照常可用。
两个花了点时间的机械细节:
- 那个 location 里必须加 `proxy_set_header Accept-Encoding ""`,因为 `sub_filter` 无法重写压缩后的内容。它只作用于外壳的精确匹配,所以只有约 5 KB 失去压缩;应用的 JS 和 CSS 保持 gzip。
- 千万**不要**加 `sub_filter_types text/html`——那是默认值,加了 nginx 每次启动都会报一条 MIME type 重复的警告。
语言这一半值得单独一篇,也确实写了:[浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式](/posts/zh/adding-english-mode-to-a-chinese-only-web-app/) 讲的是那个属性谎言和应用内英文层。这里重要的只是这个手法的形状——把一个小补丁打在客户端渲染应用里唯一以真实标记形式到达的那一部分上。
## 用代理解决不了的品牌问题
同一类应用通常还会带上厂商自己的链接——一个 GitHub 图标、一个 Telegram 群、一个「关注我们」区块。学过前面那条规则,结论立刻就有了:这些按钮是 JavaScript 画出来的,所以没有代理重写碰得到它们,也没有 HTML 可以改。真正有效的是应用自己的扩展点。很多自托管应用都会留一个,这个应用会从它持久化卷里的某个目录加载一份自定义样式表:
```css
.bottom-icons a { display: none !important; } /* the vendor's repo link */
.index-wrapper > :nth-child(6 of .setting-wrapper) { display: none !important; }
```
因为这份文件住在数据卷而不是镜像里,它会活过每一次镜像重建——这正是把自定义项放在发布流程够不着的地方的意义。
## 脆弱的地方,以及我现在会检查什么
**这些注入依赖两个字面量字符串:** `'<html lang="en">'` 和 `'</head>'`。如果应用未来某个版本改了其中任何一个,补丁就会静默失效:不报错,页面也不坏,只是首页回弹行为不见了,而这件事可能几周都没人发现。正因为这种失败方式,这条 stack 的运维笔记里放的是断言,而不是描述:
```bash
curl -s https://book.example.com/index.html | grep -o '<html lang="[^"]*"' # want zh-CN
curl -s https://book.example.com/index.html | grep -c 'gatePass' # want 1
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://book.example.com/og.png
```
每次应用升级之后都跑一遍。「它还能打开」不等于「我的集成还在工作」。
**永远不要手改 NAS 生成的反向代理配置。** 网关上面那一层是平台写的,它每隔几分钟就用一个新的 UUID 文件名重新生成——手改的内容活到下一次重新生成为止,然后消失,通常是在你正在查别的问题的时候。所以那一层被当成不可变的,永久指向网关的端口;我控制的一切都活在它下面。
**把这扇前门留在应用镜像之外。** 整个设计就压在这个决定上。网关是独立的容器,配置文件在宿主机上,所以一次应用镜像替换——或者,像后来发生的那样,一次换了编程语言的上游重写——都带不走首页、SEO 元数据和翻译层。
**还有:用断言,不要用眼睛。** 上面那段里每一个数字都来自 `curl` 和字节数,而不是「我看了一眼,感觉没问题」。
## 结果
应用获得了一个首页、一段邀请说明、一张社交卡片、JSON-LD、中英切换、一层英文界面,并且被去掉了厂商品牌——而它本身一行都没有改:
| 检查项 | 结果 |
|---|---|
| `/`(首页) | 200,23 KB,`Cache-Control: no-store` |
| 应用外壳 | 200,`<html lang="zh-CN">`——那个语言谎言在路上被纠正 |
| 注入脚本存在 | 1 处 |
| `/og.png` | 200,`image/png`,185 KB |
| 应用容器端口 | 只在 loopback;对外端口属于网关 |
两个容器而不是一个,大约四十行 nginx,外加一扇应用的发布周期够不着的前门。当底下那个应用永远是别人的问题时——它永远是——这就是放你自己品牌最便宜的位置。
---
**你在把自托管应用当服务来跑——给客户、给同事、给付费用户——希望它别再像个原始安装?** 在一个你不维护的应用前面加上自己的前门、品牌和语言处理,正是我在做的一类封装工作。
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Front%20door%20for%20a%20self-hosted%20app) ·
[hoelee.com](https://hoelee.com)
@@ -0,0 +1,111 @@
---
title: "上游被删了,然后带着一个重写版回来"
description: "我线上跑的阅读服务,依赖的镜像官方仓库已经 404。几个月后上游回来了——233 个提交、换了语言、零 release。怎么分辨「项目死了」和「项目在重写」。"
pubDate: 2026-10-06
category: devops
tags: ["docker", "self-hosting", "upstream", "maintenance", "registry"]
ogImage: "/og/the-upstream-was-deleted-then-came-back-rewritten.png"
banner: "/banners/the-upstream-was-deleted-then-came-back-rewritten.png"
draft: false
---
`docker pull hectorqin/reader` 返回 **404**。而这正是我线上阅读服务所依赖的项目:一个只发邀请码的小书库,给我家人和几位付费读者用,每天在跑,上面还搭了一条我自己做的语音朗读管线,让「朗读」按钮念出来的是正经的神经网络音色。
404 还不是最有意思的部分。**2026-09-16**,同一个仓库被从零重新初始化——提交信息是 `chore: 初始化仓库`——十六天后,它已经有了 **233 个提交**、另一门语言、另一个端口、另一种数据库、另一个镜像仓库。我依赖的项目没有死,它是被**一个重写版替换了**,名字没变、地址没变、那 11,036 颗星也没变。
所以问题是:**怎么判断一个上游项目是死了,还是正在被重写?而当它以另一个应用的样子回来时,你该怎么做?**
## 先说我这边的情况,因为细节才是这件事的关键
这个应用是一个自托管阅读服务器。浏览器端是 hash 路由的 Vue SPA,服务端是 Kotlin + Spring Boot,配置按用户存成磁盘上的 JSON。而我专门为它的一条能力搭了一整套管线:**HTTP 语音合成**。阅读器会把朗读请求代理到你配置的 URL,也就是说音色不是应用自带的——我的指向 n8n 的两个 webhook,由它们去换取短期 Azure / Google 凭据并合成音频。语音选择器里那 9 个引擎是这么来的,不是应用自带的。
此外,应用的镜像外面还有我自己的一层 nginx 网关容器:对外落地页、社交分享卡片、中英切换、以及一层英文界面补丁,全在那里,不在应用镜像里。当初做这个决定是为了品牌,而正是它让整件事从头到尾都很平静——后面我会回到这点。
## 我去找升级路径的时候,发现了什么
三个选项,没有一个叫「升级」:
| 选项 | 它实际是什么 | 我为什么没选 |
|---|---|---|
| 官方镜像 | Docker Hub 上的 `hectorqin/reader` | 没了。仓库返回 404,`docker pull` 直接失败 |
| 还在维护的 fork | `changshengyu/reader`,版本 `2.5.4` | 基于最后一个完全开源的快照。它的朗读只用浏览器自带的 `speechSynthesis`,没有 HTTP 语音。它的整个历史里没有任何 `httpTTS` 字样,`/reader3/httpTts` 也是 404。我那 9 个引擎会全部消失 |
| 别人重新打包的镜像 | `liangnianzhi/reader.hectorqin:latest-20250525` | **这就是我现在跑的。** 唯一还能拉取、并且保留了 HTTP 语音的 3.x 构建。最后更新 2025-09-05,一共三个标签,发布者不是原作者 |
我把那个标签钉死,把数据目录打包备份,把源码镜像到自己的 Git 服务器上——至少我依赖的代码得存在于我能控制的地方。然后把那句决定未来任何镜像是否有资格的话写了下来:**它是否还讲我这条管线所实现的 TTS 协议?**
这就是当时的处境:一个跑在「官方仓库已被删除」的镜像上的服务,靠着某个陌生人重新打包的构建活着。
## 然后上游回来了
以下数据于 2026-10-06 直接取自 GitHub API 与镜像仓库:
| 指标 | 数值 |
|---|---|
| 仓库 | `hectorqin/reader` —— 活着,AGPL-3.0,默认分支 `main` |
| Star / Fork | 11,036 / 5,471 |
| 最后推送 | 2026-10-02 |
| 提交数 | **233** —— 而最早的一条是 `chore: 初始化仓库`,日期 **2026-09-16** |
| release / tag | **0 / 0** |
| Docker Hub | 仍然 **404** |
2021 年那段历史没了。`created_at` 还显示 2021-08-13,只是因为 GitHub 在仓库被重新填充后保留了这个字段,但 git 历史是从 2026-09-16 一条空仓库提交开始的。被删除的痕迹还留在旧分支上:去取 `master` 的 README,全文只有两个字——`deleted`。
而这 233 个提交里的代码,不是我原来那个代码库的延续。它是另一个应用,只是恰好沿用了同一个名字:
| | 我现在跑的(3.x) | 上游现在的样子 |
|---|---|---|
| 运行时 | Kotlin,Spring Boot + Vert.x | TypeScript,Node |
| 端口 | 8080 | 5888 |
| 数据 | `/storage/data`,按用户存 JSON | `/data`,SQLite(`reader.db`) |
| 配置 | 环境变量 + 每用户 JSON 文件 | 管理后台界面,存进数据库 |
| 镜像 | Docker Hub,命名空间已删除 | `cnb.cool/hectorqin/reader:main` |
| 范围 | 只有书 | 书 **和** 电影、剧集、音乐、有声书、OPDS、OpenList、PWA、Android 客户端 |
表里有三个细节值得单独拎出来,因为它们各自都有一个后果:
**镜像仓库搬去了一个大多数人从没拉过东西的平台。** 官方 compose 里写的是 `image: ${READER_IMAGE:-cnb.cool/hectorqin/reader:main}`,旁边一行注释解释了原因:CI 只发布**分支和提交标签,刻意不发布 `:latest`**。也就是说默认配置跟的是一个会动的分支,唯一稳定的引用是一个 commit SHA。
**配置离开了环境变量。** 业务设置——HTTP 语音接口、扫描行为、登录有效期、对外 URL、允许的浏览器来源、WebDAV——现在都在管理后台里,持久化进数据库。文档说明:首次升级时「旧版业务环境变量会被导入一次」,并且已存在的数据库值永远优先。这是一次性的导入加一条优先级规则,正是你希望在副本上验证、而不是在线上实例上发现的东西。
**HTTP 语音回来了,只是换了个地方。** 新版本重新提供了 HTTP 语音上游的配置:合成 URL、token、voices URL、超时、缓存上限,还有连接测试和试听,全部在管理后台里配。而我的 n8n webhook 实现的协议,是我照着旧版构建自己设计的。这两个形状是否匹配,现在是一个需要用实验回答的问题,不是查文档就能回答的问题。
## 没人提醒你的部分:重写版是一个新应用
本能反应是把「上游复活了,还多了影音支持和 Android 客户端」读成「我等的升级到了」。并不是。一次重写会同时改变数据格式、部署面和配置模型——这意味着要做的是**一场带 schema 边界的迁移**,而不是 `docker pull` 加一次重启。
让我决定慢下来的,是项目自己的进度记录。不要拿厂商的营销页当状态报告,去看它自己写的东西,你会看到作者在列**没做完**的事情:
- 旧数据**自动迁移和首次接入向导都还没实现**;
- **镜像拉取与真实容器升级演练从未执行过**,因为构建机上没有跑 Docker 引擎。
这是一份诚实得罕见的文档,也是整个仓库里最有用的一个文件。重写的作者在告诉我:升级路径写好了,但没人走过。如果连他们都没走过,我当然不会在工作日下午、在我读者正在使用的实例上、在没有数据副本的情况下走去试。
## 我现在会把一个依赖交给线上服务之前做的事
1. **仓库健康不等于产物健康。** `pushed_at`、star、fork 说明**源码**还活着;对你要跑的那个确切标签执行 `docker pull`,才能说明**你部署的东西**还在。两个都要查,并读一遍 compose 看它到底指向哪个镜像仓库——我这个现在指向一个我本周之前从没打开过的域名。
2. **「没有 release、没有 tag」是一条部署事实。** 零 tag 意味着每次升级要么写死一个提交哈希,要么跟一个会动的分支。把那个 SHA 记进自己的笔记,上游不会为你记。
3. **你输不起的东西,就镜像一份——源码和镜像都要。** 一个钉在别人命名空间里的标签,是一种「那个陌生人账号别出事」的依赖。我活下来的 3.x 源码现在在我自己的 Git 服务器上,重写版也是。
4. **先比形状,再比功能。** 端口、挂载路径、变量名、数据库引擎、配置存放位置。如果这些大多变了,就把它当成一个新产品,按迁移来规划。
5. **你重建不了的部件,放在你控制不了的镜像之外。** 我的落地页、分享卡片、语言切换和翻译层,是同一条 stack 里的另一个容器。正因为如此,上游这次重写没有打扰到任何一个读者——应用镜像怎么换,都碰不到前门。
6. **读维护者的「还没做」清单,而不是功能清单。** 功能清单是销售页,未完成清单才是风险登记表。
7. **如果你把它当成对外服务来跑,读一下许可证。** 这个是 AGPL-3.0,如果你拿它给付费用户提供服务、而不是自己用,值得认真读一遍。
8. **「仓库被归档或被清空」不等于「项目被放弃」。** 有好几个月,「这个项目死了吗」的诚实答案是「是」——README 就写着 `deleted`,而且它说的是实话。然后有人重新开始了,换了门语言,而诚实的答案在名字不变的情况下变了。
## 结果
书库在跑,跑在我钉住的那个构建上,读者完全无感——这本来就是目标,而且它一直成立。变的是:「我们被困在一个没人管的镜像上」从一句含糊的担忧,变成了一个有方案的具体问题:
- 数据目录已复制,新镜像自带的 `backup` / `verify` / `restore` 命令先**对着副本**跑,确认没问题才碰线上卷;
- TTS 协议要拿我现有的 webhook 实测,因为一旦对不上,我丢掉的就是朗读这个功能;
- 旧镜像标签留着做回滚,而且无论走哪条路,源码现在都在我自己的服务器上;
- 前门不在讨论范围内,因为它在另一个容器里,换镜像够不着它。
真正有用的教训不是「要钉版本」——那个我早就做了。而是:**「这个项目还活着吗」有三个答案,而它们可以互相矛盾**。仓库可以活着,而镜像已被删除;README 可以写着 `deleted`,而重写版已经开工四周;版本号可以纹丝不动,而运行时、端口、数据库和配置模型在底下全换了一遍。这三件事要分别去查,否则你迟早会为一个「已经不再以你测试过的形态存在」的应用规划升级。
---
**你在替别人跑自托管服务,但不确定底下的东西还有没有人管?** 这正是我在做的审计:依赖与镜像健康度、升级可行性,以及随之而来的迁移方案。
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Self-hosted%20dependency%20audit) ·
[hoelee.com](https://hoelee.com)