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:
2026-10-06 17:19:46 +08:00
parent 67b844e3a0
commit 378d55c3c8
10 changed files with 732 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+37
View File
@@ -1112,6 +1112,43 @@ BANNERS['running-production-infrastructure-solo'] = {
],
};
BANNERS['the-upstream-was-deleted-then-came-back-rewritten'] = {
titlebar: "root@dsm — reader · the upstream came back",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "docker pull hectorqin/reader" },
{ t: 'err', text: "→ 404 the official image is gone from Docker Hub" }, { t: 'prompt', text: "$" },
{ t: 'cmd', text: "git log --oneline | tail -1" }, { t: 'err', text: "233 commits, oldest: 初始化仓库 (2026-09-16)" },
{ t: 'hl', text: "Kotlin/Spring + Vert.x → TypeScript/Node · :8080 → :5888" }, { t: 'dim', text: "registry moved to cnb.cool — no tags, no releases" },
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "keep the front door outside the app image" },
{ t: 'ok', text: "→ readers unaffected · migration plan written ✓" },
],
flow: [
{ n: '1', label: "orphaned image" },
{ n: '2', label: "404 on Docker Hub", err: true },
{ n: '3', label: "233 commits, new language" },
{ n: '4', label: "a rewrite = a new app" },
{ n: '5', label: "tested migration plan ✓" },
],
};
BANNERS['putting-a-front-door-on-an-app-you-cant-modify'] = {
titlebar: "root@dsm — reader-gateway · nginx:alpine",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "curl -s book.example.com/index.html | wc -c" },
{ t: 'err', text: "5879 — a shell: one empty div, everything else drawn by JS" }, { t: 'prompt', text: "$" },
{ t: 'cmd', text: "body-rewrite? · patch the bundle? · fork it?" }, { t: 'err', text: "nothing in the bytes to rewrite" },
{ t: 'hl', text: "a second container owns the public port" }, { t: 'cmd', text: "location = / → my page · location / → the app · resolver at request time" },
{ t: 'dim', text: "app port → 127.0.0.1:7778 · branding via the app's own CSS hook" }, { t: 'ok', text: "→ front page · og card · 中/EN · vendor links gone ✓" },
],
flow: [
{ n: '1', label: "blank login box" },
{ n: '2', label: "client-rendered SPA", err: true },
{ n: '3', label: "gateway container" },
{ n: '4', label: "page + 3 injections" },
{ n: '5', label: "front door ✓" },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+12
View File
@@ -337,6 +337,18 @@ TERMINALS['running-production-infrastructure-solo'] = `
<div class="line"><span class="prompt"> </span><span class="err">grafana: one NAS volume counted three times</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">verify_infra.py — re-derive from the live daemons</span><span class="fix">→ no remembered numbers ✓</span></div>`;
TERMINALS['the-upstream-was-deleted-then-came-back-rewritten'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">docker pull hectorqin/reader</span><span class="err">→ 404 Not Found</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">my 3.x build survives in a stranger's Docker Hub namespace</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">git log --oneline | wc -l</span><span class="fix">→ 233 commits since 2026-09-16</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">port · volume · env · db engine · config store</span><span class="err">→ all five changed: it's a new app</span></div>`;
TERMINALS['putting-a-front-door-on-an-app-you-cant-modify'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">curl -s book.example.com/index.html | wc -c</span><span class="err">→ 5879 · body is <div id="app"></div></span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">client-rendered → a proxy body rewrite has nothing to rewrite</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">docker compose up -d (gateway takes the public port)</span><span class="fix">→ front page 200 ✓</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">curl -s /index.html | grep -o lang=</span><span class="fix">→ zh-CN ✓</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,216 @@
---
title: "Putting a Front Door on an App You Can't Modify"
description: "A self-hosted app whose front page was a Chinese login box — and a client-rendered SPA I couldn't edit. So I added a second container: nginx, a bilingual page, and three injected lines."
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
---
The first thing a visitor to my reading service saw was a Chinese login box.
No explanation of what the site was, no invitation information, no branding,
no social card when the link was shared, and nothing a search engine could
sensibly index. Just a login dialog with a poem above it.
I run that service for my family and a handful of paying readers, so "what
does a new reader see first" is not a cosmetic question — it is the product.
And I could not change it, because the app behind it is somebody else's
code: a hash-routed Vue single-page application whose interface I do not
control and whose releases arrive on their schedule, not mine.
**How do you put your own front page, branding and language handling on a self-hosted app you can't edit — without forking it and without a wrapper that breaks on the next image update?**
## Why the obvious fixes don't work
**Edit the app's HTML.** There is nothing there to edit. This is the part people skip, and it decides the whole approach:
```bash
curl -s https://book.example.com/index.html | wc -c
# 5879 — and the body is <div id="app"></div>
```
The page ships as a shell. Every button, label and dialog is drawn later by
JavaScript. So there is no markup to improve and nothing to reorder.
**Rewrite the response in the proxy.** This is the tempting one — Traefik or nginx middlewares that rewrite response bodies. It fails for the same reason: a body rewrite can only rewrite what is *in the body*, and what's in the body is an empty div and a script tag. This is worth internalising as a general rule before you reach for any reverse-proxy rewrite feature:
> `curl` the raw response and grep for the string you want gone. If it isn't in the bytes you receive, the content is client-rendered and a body rewrite is the wrong tool — you need CSS or JS, injected somewhere the app will load it.
**Patch the minified bundle.** Technically possible, since I can see the compiled files. But every image update ships rewritten — and sometimes re-minified — bundles, so the patch is a permanent maintenance duty against a codebase I don't own. It also breaks in the worst way: silently, in production, after an unrelated upgrade.
**Put my page inside the app's image.** Now my landing page, SEO tags and social card are coupled to somebody else's release cadence, and a bad upstream build takes my front door down with it. The thing I wanted least was to make my brand depend on their Dockerfile.
So: not inside the app, not a rewrite of the app, and not a fork. In front
of it.
## The design: one extra container that owns the public port
The stack has two services. The app publishes no public port; a tiny
`nginx:alpine` gateway owns it and decides what each request is:
```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
```
The nginx side is short enough to read in full. The interesting decisions
are the four lines around the routing, not the routing itself:
```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
}
```
**Resolve the upstream at request time.** With a literal `proxy_pass http://reader:8080`, nginx resolves that name once, at startup, and refuses to start if it can't — so the front page would go down whenever the app is restarting or being replaced. Using a variable plus Docker's embedded DNS resolver (`127.0.0.11`) defers resolution to each request. The gateway boots fine with the app stopped, which matters the first time you recreate the app and the last time you debug at midnight.
**Keep the app's own port for debugging, and move it in one pass.** The app still publishes `127.0.0.1:7778:8080` on loopback — reachable from the host over SSH, invisible to the internet. When I made that switch, the old container had to release the public port in the *same* stack update that gave it to the gateway; splitting it into two deploys fails to bind.
**Don't let the middle box become the new limit.** The gateway sets `client_max_body_size 1024m` because the app accepts large book uploads. A proxy in front of an app with file uploads is a classic way to introduce a bug that only appears on the one day somebody uploads something big.
**Different cache lifetimes per route.** The root page is `no-store` (I edit it directly on disk and want the change live on refresh), the social image is cached for a day, the injected script for five minutes. One line each.
## The page itself
It is a single self-contained HTML file: no web fonts, no CDN, no build
step. That's not minimalism for its own sake — it means the front door has
no third-party dependency that can be slow, blocked or discontinued, and it
renders from cache when the network is down.
Two decisions inside it are worth copying:
**Both languages live in the DOM at once.** Every string exists twice — a `.zh` span and an `.en` span — and one attribute on `<html>` decides which set is visible through CSS. The default follows the browser's language, a `?lang=en` / `?lang=zh` query parameter overrides it, and the choice is remembered in `localStorage`. Because both languages are in the HTML, a search engine indexes both, which a JavaScript-based switcher would not achieve.
**The invitation code is not on the page — deliberately.** The page explains that the code is printed on the physical invitation card and says so in plain language, rather than showing it. A public landing page that leaks the registration code turns an invite-only service into an open one.
## Three lines injected into the app, and why they're the difference
A landing page in front of an app still feels like two products unless the
seam is hidden. Three `sub_filter` rules in the app's location block do that
work — they patch the app's own shell (5,879 bytes of it) as it passes
through, which is the one part of a client-rendered app that *is* in the
bytes:
```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>';
}
```
The second one is the interesting one. Reloading, using a bookmark, or
opening a new tab should land on the front page again — I wanted the app's
address to behave like a product, not to dump a stranger into an app
interior with no exit. The mechanism is a `sessionStorage` flag: no flag
means "bounce to the front door", the enter button sets the flag first, and
the script **consumes** it (deletes it) so the next reload bounces again.
Without that consumption step you get an infinite ping-pong between the page
and the app, and the browser tab becomes unclosable-by-logic. The
`navigator.onLine` guard lets offline use through, so the app still works as
an installed app with no network.
Two mechanical notes that cost me time:
- `proxy_set_header Accept-Encoding ""` is required in that location, because `sub_filter` cannot rewrite a body that arrived gzipped. It's scoped to an exact match on the shell so only ~5 KB loses compression; the app's JavaScript and CSS keep theirs.
- Do **not** add `sub_filter_types text/html` — that is the default, and adding it makes nginx log a duplicate MIME type warning on every start.
The language fix deserves its own post, and it got one: [No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App](/posts/adding-english-mode-to-a-chinese-only-web-app/) covers the attribute lie and the in-app English layer in detail. What matters here is the shape of the trick — a small patch applied to the one part of a client-rendered app that arrives as real markup.
## The branding problem you can't solve with a proxy
The same class of app usually carries the vendor's own links — a GitHub
icon, a Telegram group, a "follow us" block. Once you know the rule from
earlier, the conclusion is immediate: those buttons are drawn by JavaScript,
so no proxy rewrite can touch them and no HTML edit exists to make. What
does work is the app's own extension point. Many self-hosted apps ship one,
and this one loads a custom stylesheet from a directory inside its
persistent volume:
```css
.bottom-icons a { display: none !important; } /* the vendor's repo link */
.index-wrapper > :nth-child(6 of .setting-wrapper) { display: none !important; }
```
Because that file lives in the data volume rather than the image, it
survives every image rebuild — which is the whole point of putting
customisation where the release process can't reach it.
## What's fragile, and what I now check
**The injections depend on two literal strings.** `'<html lang="en">'` and `'</head>'`. If a future app version changes either one, the patch silently does nothing: no error, no broken page, just a missing front-door behaviour that nobody notices for weeks. That failure mode is why the operational notes for this stack carry the assertions instead of a description:
```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
```
Run them after every app upgrade. "It still loads" is not the same as "my
integration still works".
**Never hand-edit the NAS's generated reverse-proxy config.** The layer above the gateway is written by the platform, which regenerates it with a new UUID filename every few minutes — a hand edit lives until the next regeneration and then vanishes, usually while you're debugging something else. So that layer is treated as immutable and stays pointed at the gateway's port permanently; everything I control lives below it.
**Keep the front door out of the app's image.** This is the decision the whole design rests on. The gateway is a separate container with its own files on the host, so an app image swap — or, as it turned out later, an upstream rewrite in a different programming language — cannot take the front page, the SEO metadata or the translation layer with it.
**And assert, don't eyeball.** Every claim in the paragraph above came from `curl` and a byte count, not from looking at the page and deciding it seemed fine.
## The result
The app got a front page, an invitation explanation, an og card, JSON-LD, a
bilingual switch, an English-UI layer and its vendor branding removed — and
I did not modify one line of it:
| Check | Result |
|---|---|
| `/` (the front page) | 200, 23 KB, `Cache-Control: no-store` |
| The app shell | 200, `<html lang="zh-CN">` — the language lie corrected in flight |
| Injected script present | 1 occurrence |
| `/og.png` | 200, `image/png`, 185 KB |
| App container port | loopback only; the public port belongs to the gateway |
Two containers instead of one, about forty lines of nginx, and a front door
the app's own release cycle can't reach. When the app underneath is somebody
else's problem — which it always is — that's the cheapest place to put your
brand.
---
**Running a self-hosted app as a service — for clients, staff or customers — and want it to stop looking like a raw install?** Putting your own front door, branding and language handling in front of an app you don't maintain is the kind of packaging work I do.
[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,181 @@
---
title: "The Upstream Was Deleted, Then Came Back as a Rewrite"
description: "My production reading service runs a build whose official Docker Hub repo returns 404. Then upstream came back — 233 commits, a new language, no releases. How to tell dead from rewriting."
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` returns **404**. That is the project my
production reading service runs on: a small invite-only library I host for
my family and a handful of paying readers, serving books every day, with a
text-to-speech pipeline I built on top of it so the read-aloud button speaks
in proper neural voices.
The 404 is not the interesting part. On **2026-09-16** that same repository
was re-initialised from scratch — the commit message is `chore: 初始化仓库`,
"initialise repository" — and sixteen days later it carried **233 commits**,
a different language, a different port, a different database engine and a
different container registry. The project I depend on did not die. It was
**replaced by a rewrite** under the same name, the same URL and the same
11,036 stars.
So: **how do you tell whether an upstream project is dead — and what do you
do when it comes back as a different application?**
## The setup, because the details are why this mattered
The app is a self-hosted reading server. Hash-routed Vue SPA in the browser,
Kotlin and Spring Boot on the server, per-user JSON config on disk, and one
feature I had deliberately built a whole pipeline around: **HTTP
text-to-speech**. The reader proxies read-aloud requests to a URL you
configure, so the voices are not baked into the app — mine point at two n8n
webhooks that mint short-lived Azure and Google credentials and synthesise
the audio. Nine engines show up in the voice picker because of that, not
because the app shipped them.
I also run my own nginx gateway container in front of the app — the public
landing page, the social card, the bilingual switch and an English-UI layer
all live there rather than inside the app's image. That decision, made for
branding reasons, is the reason this whole incident stayed boring. I'll come
back to it.
## What I found when I went looking for an upgrade
Three options, and none of them was "upgrade":
| Option | What it actually is | Why I didn't take it |
|---|---|---|
| The official image | `hectorqin/reader` on Docker Hub | Gone. The repository returns 404; `docker pull` fails |
| The maintained fork | `changshengyu/reader`, pinned at `2.5.4` | Built on the last fully open snapshot. Its read-aloud uses the browser's own `speechSynthesis` only — no HTTP speech. No `httpTTS` string anywhere in its history, and `/reader3/httpTts` 404s on it. My nine engines would vanish |
| A rebuild published by someone else | `liangnianzhi/reader.hectorqin:latest-20250525` | **This is what I run.** The only still-pullable 3.x build that kept HTTP speech. Last updated 2025-09-05, three tags total, published by a user who is not the original author |
I pinned that tag, tarred up the data directory, and mirrored the source
into my own Git server so the code I depend on existed somewhere I control.
Then I wrote down the one line that decides whether any future image is even
a candidate: **does it still speak the TTS protocol my pipeline
implements?**
That was the state of things: a running service on an image whose official
home had been deleted, surviving as somebody's rebuild.
## Then upstream came back
Verified on 2026-10-06, straight from the GitHub API and the registry:
| Signal | Value |
|---|---|
| Repo | `hectorqin/reader` — alive, AGPL-3.0, default branch `main` |
| Stars / forks | 11,036 / 5,471 |
| Last push | 2026-10-02 |
| Commits | **233** — and the oldest is `chore: 初始化仓库`, dated **2026-09-16** |
| Releases / tags | **0 / 0** |
| Docker Hub | still **404** |
The 2021 history is gone. The `created_at` timestamp still reads 2021-08-13
because GitHub keeps that field when a repository is re-populated, but the
git history begins on 2026-09-16 with an empty-repository commit. There is
also a ghost of the deletion still sitting on the old branch: fetch
`master`'s README and its entire contents are the single word `deleted`.
And the code behind those 233 commits is not my app's codebase continued. It
is a different application that happens to keep the name:
| | What I run (3.x) | What upstream is now |
|---|---|---|
| Runtime | Kotlin, Spring Boot + Vert.x | TypeScript, Node |
| Port | 8080 | 5888 |
| Data | `/storage/data`, per-user JSON | `/data`, SQLite (`reader.db`) |
| Configuration | environment variables + per-user JSON files | the admin UI, persisted in the database |
| Container image | Docker Hub, namespace deleted | `cnb.cool/hectorqin/reader:main` |
| Scope | books | books **and** movies, series, music, audiobooks, OPDS, OpenList, PWA, an Android client |
Three details are worth pulling out of that table, because each one has a
consequence:
**The registry moved to a platform most of us have never pulled from.** The shipped compose file uses `image: ${READER_IMAGE:-cnb.cool/hectorqin/reader:main}`, and a comment explains that CI publishes **branch and commit tags and deliberately does not publish `:latest`**. So the default configuration tracks a moving branch, and the only stable reference is a commit SHA.
**Configuration left the environment.** Business settings — the HTTP speech endpoint, scan behaviour, login lifetimes, the public URL, allowed browser origins, WebDAV — now live in the admin UI and persist in the database. The docs note that on first upgrade "legacy business environment variables are imported once", and that existing database values always win. That is a one-shot import with a precedence rule, which is exactly the kind of thing you want to test on a copy instead of discovering on your live instance.
**HTTP speech came back — in a different place.** The new version documents an HTTP speech upstream again, with a synthesis URL, a token, a voices URL, a timeout and a cache limit, plus a connection test and audio preview, all configured in the admin UI. My n8n webhooks implement a protocol I designed against the old build's expectations. Whether those two shapes match is now an empirical question, not a documentation question.
## The part nobody warns you about: a rewrite is a new application
The instinct is to read "upstream is alive again, with media support and an
Android client" as *my upgrade arrived*. It isn't. A rewrite changes the
data format, the deployment surface and the configuration model at the same
time — which means the work is a migration with a schema boundary in it, not
a `docker pull` and a restart.
What convinced me to slow down was the project's own progress note. Read it
for yourself rather than taking a vendor's marketing page as the status
report, and you find the authors listing what is **not** done:
- legacy data **auto-migration and a first-run setup wizard are not implemented**;
- the **image pull and the real container upgrade drill were never executed**, because the Docker engine was not running on the build machine.
That is unusually honest documentation, and it is the single most useful
file in the repository. The people writing the rewrite are telling me the
upgrade path has been written but not walked. If they haven't walked it, I'm
certainly not walking it on the instance my readers are using, on a Tuesday,
without a copy of the data.
## What I do now, before trusting any dependency with a running service
1. **Repository health is not artifact health.** `pushed_at`, stars and
forks tell you the *source* is alive. `docker pull` on the exact tag you run
tells you the *thing you deploy* still exists. Check both, and read the
compose file to see which registry it names — mine now points at a domain I
had never opened before this week. 2. **"No releases, no tags" is a
deployment fact.** Zero tags means every upgrade is a commit hash or a
moving branch. Write the SHA in your own notes, because the upstream will
not. 3. **Mirror anything you cannot afford to lose — the source *and* the
image.** A pinned tag in someone else's namespace is a dependency on that
stranger's account staying alive. My surviving 3.x source sits in my own Git
server now, and so does the rewrite. 4. **Compare the shape before the
features.** Port, volume paths, variable names, database engine, where
configuration lives. If most of those moved, treat the new version as a new
product and plan a migration. 5. **Keep what you can't rebuild outside the
image you don't control.** My landing page, social card, language switch and
translation layer are a separate container in the same stack. That is why a
rewrite landing upstream did not disturb a single reader — the front door
cannot be broken by an app image swap. 6. **Read the maintainer's "not done
yet" list, not the feature list.** The feature list is a sales page; the
unfinished list is the risk register. 7. **Check the licence if you run it
as a service.** This one is AGPL-3.0, which is worth reading properly if you
host it for paying users rather than just yourself. 8. **An archived or
wiped repository is not the same as an abandoned project.** For months the
honest answer to "is this project dead?" was yes — the README said `deleted`
and meant it. Then somebody started again, in another language, and the
honest answer changed without the name changing.
## The result
The library is up, on the build I pinned, with readers unaffected — that was
always the goal, and it never stopped being true. What changed is that
"we're stranded on an orphaned image" became a known quantity with a plan:
- the data directory is copied, and the new image's own `backup` / `verify` / `restore` commands get run **against the copy** before anything touches the live volume;
- the TTS protocol gets tested against my existing webhooks, because if it doesn't match, the read-aloud feature is the thing I lose;
- the old image tag stays present for rollback, and I now have the source on my own server either way;
- the front door stays out of scope, because it lives in a different container and an image swap cannot reach it.
And the useful lesson is not "pin your versions", which I already did. It is
that **the question "is this project alive?" has three answers, and they can
disagree**: the repository can be alive while the image is deleted; the
README can say `deleted` while a rewrite is four weeks into existence; and
the version number can stay the same while the runtime, the port, the
database and the configuration model all change underneath it. Check each
one separately, or you will eventually plan an upgrade for an application
that no longer exists in the shape you tested.
---
**Running a self-hosted app for other people, and not sure whether the thing underneath it is still supported?** That's the kind of audit I do — dependency and image health, upgrade feasibility, and the migration plan that comes with it.
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Self-hosted%20dependency%20audit)
· [hoelee.com](https://hoelee.com)
@@ -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)