Compare commits

...
2 Commits
Author SHA1 Message Date
Michilis 4e22db5261 Merge pull request 'Serve the prerendered site from Node instead of nginx root.' (#6) from dev into main
Reviewed-on: #6
2026-08-25 04:28:30 +00:00
michilisandCursor 79a115be38 Serve the prerendered site from Node instead of nginx root.
Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:27:27 +02:00
6 changed files with 875 additions and 125 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/
.astro/
api/data/
deploy/
*.log
.DS_Store
.env
+323 -125
View File
@@ -210,7 +210,7 @@ ships at `/fonts/OFL.txt`.
They sit in `src/assets/` rather than `public/` so the build fingerprints them into
`/_astro/`, which is what makes `Cache-Control: immutable` honest and puts them under a
cache rule the nginx config already has. Unhashed and uncached they are re-fetched on
cache rule `web/server.mjs` already has. Unhashed and uncached they are re-fetched on
every client-side navigation — the router re-inserts their preload links on each swap —
and the typefaces visibly reload from page to page.
@@ -305,7 +305,8 @@ hashed into its filename (`mint.example.com.a1b2c3d4e5.png`) and
is also the cache-busting story — link-preview scrapers cache an `og:image` by URL, so
a mint whose rating moved or that went offline gets a new URL on the next scheduled
rebuild, while the stable-named `default.png` (brand plus network stats, used by every
non-mint page) is served with a one-hour cache instead (see the nginx block below).
non-mint page) is served with a one-hour cache instead — the one exception in
`cacheControl()` in `web/server.mjs`.
The images are one English render shared by every locale; titles, descriptions and alt
text translate per page. Relative times stay out of the PNGs on purpose — a "3d ago"
@@ -601,8 +602,8 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
`PUBLIC_API_URL` deliberately does **not** fall back to `API_URL`: that is a build-machine
address, and a visitor's browser cannot reach `127.0.0.1`. Left empty, icon `src`
attributes and island fetches are same-origin paths (`/icons/...`, `/api/...`), which the
dev server proxies to `API_URL` and nginx forwards in production — see "Static site behind
nginx" below.
dev server proxies to `API_URL` and nginx forwards in production — see "nginx" under
Deployment below.
Set it only when the API answers on its own origin:
@@ -753,10 +754,11 @@ and capped at two with every hop re-checked, a 256KB response cap, and a per-IP
`INDEX_RATE_LIMIT` an hour with a clean `429`. Two simultaneous submissions of one address
share a single probe. `pnpm --filter ./api test:index` covers all of it without a network.
The limit needs to be able to tell two visitors apart, so behind the nginx block below add
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` to `location /api/`. The
last entry of that header is the one used, because it is the one the proxy vouched for;
the header is ignored entirely when the peer is not loopback.
The limit needs to be able to tell two visitors apart, which is what `include proxy_params;`
on every proxied location in the nginx block below is for — it is what sets
`X-Forwarded-For`, and without it every submission arrives from the loopback peer and
shares one budget. The last entry of that header is the one used, because it is the one
the proxy vouched for; the header is ignored entirely when the peer is not loopback.
### Ranking
@@ -780,193 +782,368 @@ literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
## Deployment
### API as a systemd unit
Three units and an nginx block. Everything this project runs listens on loopback and
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
file belonging to the project.
```
:443 nginx ──► 127.0.0.1:8789 cashumints-site.service the prerendered site
└─► 127.0.0.1:8788 cashumints.service /api/* and /icons/*
cashumints-web.service oneshot: build, then publish to
/var/lib/cashumints/web
```
nginx used to point a `root` at `web/dist` instead of proxying. That is the arrangement
to avoid, and the reason is worth stating because the failure mode is so quiet: nginx
runs as `www-data`, everything here runs as `cashumints`, and serving files across that
boundary requires every directory from `/` down to `dist` to be traversable by a user
with no other business in the tree. A home directory left at its default `0700` breaks
the whole site, and `try_files` reports a permission error as a plain miss — so the
symptom is a blanket 404, or an internal-redirect loop that ends in a 500, with the
cause named nowhere. Proxying removes the boundary: the process that serves the files
is the one that built them.
### Node
The API and the site server both run TypeScript and ESM directly, with no build step, so
**systemd's node must be 22.18 or newer** — that is the release where native type
stripping stopped needing a flag. This is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Check the one that matters:
```bash
/usr/bin/node --version
```
On Node 20 the API exits immediately with `ERR_UNKNOWN_FILE_EXTENSION` for `.ts` and
restarts forever. Install Node system-wide rather than pointing `ExecStart` at a version
manager's path, which breaks at the next upgrade and is invisible to `ProtectHome`.
### The API
```ini
# /etc/systemd/system/cashumints-api.service
# /etc/systemd/system/cashumints.service
[Unit]
Description=cashumints.space indexer and API
After=network-online.target
Wants=network-online.target
After=network-online.target
# Stop after five failures in a minute rather than restarting forever: a process that
# cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a louder signal than a journal scrolling past. Both keys belong
# to [Unit] — under [Service] systemd only warns and ignores them.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=cashumints
WorkingDirectory=/srv/cashumints/api
ExecStart=/usr/bin/node src/index.ts
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/api
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=PORT=8787
Environment=PORT=8788
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service above.
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
# Keep ICON_DIR either way: cached icons are files, not rows.
Restart=always
RestartSec=5
ExecStart=/usr/bin/node --env-file-if-exists=../.env src/index.ts
Restart=on-failure
RestartSec=5s
# The process finishes its in-flight probe batch and closes the database on SIGTERM.
KillSignal=SIGTERM
TimeoutStopSec=30
TimeoutStopSec=30s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now cashumints-api
`Environment=` in the unit and `--env-file-if-exists=../.env` are not interchangeable.
Anything set in the unit wins, so a value that must not drift with an edit to `.env`
belongs in the unit; a secret belongs in `.env`, which is not world-readable.
### The site server
`web/server.mjs` serves the prerendered tree and nothing else — no template, no database
handle, no route table. It is a few hundred lines of `node:http` with no dependencies,
and `pnpm --filter ./web test` covers what it has to get right: a miss answers 404
rather than 200 with a 404-shaped body, a miss under a locale stays in that locale,
hashed assets are immutable and markup is not, and nothing outside the root is readable
however the path is spelled.
Two behaviours are worth knowing about because they are load-bearing:
- **It refuses to start on a root with no `index.html`.** The failure that prevents is a
process that starts cleanly, answers every request with 404 and looks healthy to
anything watching the port.
- **It does not serve `web/dist`.** `astro build` empties `dist` before it writes, so
serving it directly means a rebuild takes the site down for the length of the build.
It serves the published copy at `WEB_ROOT` instead.
```ini
# /etc/systemd/system/cashumints-site.service
[Unit]
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/web
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=SITE_PORT=8789
Environment=SITE_HOST=127.0.0.1
Environment=WEB_ROOT=/var/lib/cashumints/web
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=15s
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
```
### Static site behind nginx
### nginx
```nginx
server {
listen 443 ssl http2;
server_name cashumints.space;
root /srv/cashumints/web;
index index.html;
# Astro emits directory-style routes, so try the directory index before 404.
location / {
try_files $uri $uri/ $uri.html /404.html;
}
# A miss under a language prefix answers in that language. Without these two blocks
# every 404 is the English one, including the ones a Spanish reader reaches by
# following a Spanish link, and the language control on it would take them to a page
# they were not on.
location /es/ {
try_files $uri $uri/ $uri.html /es/404/index.html;
}
location /nl/ {
try_files $uri $uri/ $uri.html /nl/404/index.html;
}
# Fingerprinted assets are immutable. This covers the CSS, the island bundles and the
# webfonts, which are all emitted here with a content hash in the name. Serving the
# fonts without it is visible, not theoretical: the view transition router re-inserts
# the font preload links on every navigation, so an uncached font is re-fetched on
# each page change and the typefaces flicker as they reload.
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The favicons and the manifest. Not fingerprinted, since their names are referenced
# from outside the site, so a week with revalidation rather than a year of immutability.
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
expires 7d;
add_header Cache-Control "public";
}
# The generated social cards (`pnpm og`). The default card keeps a stable name and
# changes with the site's stats, so it gets an hour; every per-mint card carries a
# content hash in its filename and a stale hash is never referenced again, so those
# are immutable. (The hash is also what actually refreshes link previews: the big
# scrapers cache an og:image by URL and ignore these headers.)
location = /og/default.png {
add_header Cache-Control "public, max-age=3600";
}
location /og/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ {
proxy_pass http://127.0.0.1:8787;
# POST /api/index is rate limited per address, and without this every visitor
# arrives as 127.0.0.1 and shares one budget.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
expires 1d;
}
error_page 404 /404.html;
}
```
Add a `location` block per language when you add one. The 404 is also the client-side
fallback for a mint discovered since the last build, and it reads the locale off its own
URL, so `/es/mint/some-new-mint` resolves that mint and renders its summary in Spanish.
### API behind nginx, with a micro-cache
`/api/mints` and `/api/stats` change at most every few minutes but can be requested by
every visitor at once. A short micro-cache absorbs that without making the data stale.
`/api/health` is deliberately excluded: it is the endpoint you page on.
The whole public surface. Two upstreams, no `root`, no `try_files`, no `error_page`: a
miss is the site server's 404 page, in the right language and with a 404 status, and an
nginx error page here would replace it with a blank one and hide which upstream failed.
```nginx
proxy_cache_path /var/cache/nginx/cashumints levels=1:2 keys_zone=cashumints:10m
max_size=256m inactive=10m use_temp_path=off;
# Keep a few connections open to each upstream rather than reconnecting per request.
# Both processes hold idle sockets longer than nginx does, so nginx is always the side
# that closes — the reverse of that race is what produces sporadic 502s under load.
upstream cashumints_site { server 127.0.0.1:8789; keepalive 16; }
upstream cashumints_api { server 127.0.0.1:8788; keepalive 8; }
server {
listen 443 ssl http2;
server_name api.cashumints.space;
listen [::]:443 ssl http2;
server_name cashumints.space;
location /api/health {
proxy_pass http://127.0.0.1:8787;
proxy_cache off;
add_header Cache-Control "no-store" always;
ssl_certificate /etc/letsencrypt/live/cashumints.space/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cashumints.space/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Set here because this is the only part of the stack that knows a request arrived
# over TLS. Note that add_header replaces rather than merges: any location declaring
# its own add_header drops this one and has to restate it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# The upstreams send no Content-Encoding, so compression is nginx's to do.
# gzip_proxied any is required — without it nginx will not compress a proxied response
# at all. The home page goes out at a fifth of its size.
gzip on;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json
application/manifest+json application/xml image/svg+xml;
# Nothing here accepts an upload; the API's largest body is an 8 KB JSON submission.
client_max_body_size 16k;
# Both upstreams are a process on this machine. A slow response is a bug, not a
# network condition, and failing fast beats holding a worker for a minute.
proxy_connect_timeout 2s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# HTTP/1.1 with an empty Connection header is what makes the keepalive pools work;
# the default 1.0 opens a new socket per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
# proxy_params (Debian) sets Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto.
# That include is load-bearing: the API's rate limiter reads the last hop of
# X-Forwarded-For to tell two visitors apart, and without it every request arrives from
# the loopback peer and shares one bucket. Do not also set X-Forwarded-For beside the
# include — declaring it twice is what produces nginx's proxy_headers_hash warning.
# Cache-Control comes from server.mjs: a year and immutable for anything with a
# content hash in its name, revalidate-every-time for markup. Nothing to restate here.
location / {
proxy_pass http://cashumints_site;
include proxy_params;
}
location ~ ^/api/(mints|stats) {
proxy_pass http://127.0.0.1:8787;
# Health must always reflect the live process. It is the endpoint you page on.
location = /api/health {
proxy_pass http://cashumints_api;
proxy_cache off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "no-store" always;
include proxy_params;
}
# /api/mints and /api/stats change every few minutes but can be asked for by every
# visitor at once. A micro-cache absorbs that without making the data stale.
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
# Serve the previous response while one request refreshes it, so a slow
# backend never becomes a slow page.
# Serve the previous response while one request refreshes it, so a slow backend
# never becomes a slow page.
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Cache-Status $upstream_cache_status always;
include proxy_params;
}
location /api/ {
proxy_pass http://cashumints_api;
include proxy_params;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
include proxy_params;
}
}
server {
listen 80;
listen [::]:80;
server_name cashumints.space;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://cashumints.space$request_uri; }
}
```
`/api/` and `/icons/` are proxied same-origin because `PUBLIC_API_URL` is empty, which is
the default. Drop those blocks only if you build with it pointing at a separate API host.
nginx 1.25 and later want `http2 on;` on its own line and warn about the `listen … http2`
form above; Debian 12 ships 1.22, where the newer form is an unknown directive. The form
above is the one that works on both.
### Rebuilds
Mint pages are prerendered, so new mints and new review counts appear at the next build.
A nightly rebuild is enough; the site stays correct in between because the islands refresh
status and reviews at runtime, and an unbuilt mint still resolves through the client-side
fallback on the 404 page.
A nightly rebuild is enough; the site stays correct in between because the islands
refresh status and reviews at runtime, and an unbuilt mint still resolves through the
client-side fallback on the 404 page.
Publishing is a separate step from building, and the separation is the point: the copy
the site server reads is only touched once a build has succeeded, so a failed build
leaves the previous site up rather than replacing it with a half-written one.
```ini
# /etc/systemd/system/cashumints-build.service
# /etc/systemd/system/cashumints-web.service
[Unit]
Description=Rebuild the cashumints.space static site
# Every page's data comes from the API over loopback, so the API has to be up.
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
# site with an empty one.
Requires=cashumints.service
After=cashumints.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=cashumints
WorkingDirectory=/srv/cashumints
Environment=API_URL=http://127.0.0.1:8787
Environment=PUBLIC_API_URL=https://api.cashumints.space
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space
StateDirectory=cashumints
Environment=NODE_ENV=production
# Where the build reaches the API. Must match PORT= in cashumints.service.
Environment=API_URL=http://127.0.0.1:8788
Environment=SITE_URL=https://cashumints.space
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
# forwards it. Declared even though it is empty, because systemd's environment wins over
# .env — so what a production build emits cannot drift with an edit to that file.
Environment=PUBLIC_API_URL=
# After= orders the start; it does not wait for the port to accept connections. At boot
# the API is still opening its database and probing, so block until it reports healthy
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
# and systemd's PATH does not include it.
ExecStart=/usr/bin/pnpm build
ExecStartPost=/usr/bin/rsync -a --delete web/dist/ /srv/cashumints/web/
# --delay-updates stages the changed files and renames them in at the end, so the window
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
# --delete-after keeps removals from landing before their replacements. Unchanged files
# — every hashed asset and card, which is nearly all of it — are not touched at all.
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
# ~500 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
# TimeoutStartSec is what bounds a Type=oneshot.
TimeoutStartSec=1800
# A nightly rebuild should not starve the API it is reading from.
Nice=10
UMask=0022
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
# ProtectHome is deliberately absent, unlike in the other two units: this one writes
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
# store are all under it.
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
```
There is deliberately no `[Install]` section: a rebuild should be scheduled, not fired on
every boot.
```ini
# /etc/systemd/system/cashumints-build.timer
# /etc/systemd/system/cashumints-web.timer
[Unit]
Description=Nightly cashumints.space rebuild
@@ -978,10 +1155,31 @@ Persistent=true
WantedBy=timers.target
```
### First deploy
Order matters once: the site server refuses to start against a root that has no
`index.html`, so the build has to publish before it comes up.
```bash
sudo systemctl enable --now cashumints-build.timer
/usr/bin/node --version # 22.18 or newer, or the API will not run
sudo systemctl enable --now cashumints # API first: the build reads from it
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
sudo systemctl enable --now cashumints-site # now it has something to serve
sudo systemctl enable --now cashumints-web.timer
sudo nginx -t && sudo systemctl reload nginx
```
```bash
curl -sI https://cashumints.space | head -1 # 200
curl -sI https://cashumints.space/nope | head -1 # 404, not 200
curl -s https://cashumints.space/api/health # status ok
```
A 502 on `/` means the site server is down or was never started; a 502 on `/api/` means
the API is. They fail independently, which is the other thing proxying buys: the
prerendered site keeps serving while the API is restarting.
## Licence
See `LICENSE`.
+1
View File
@@ -13,6 +13,7 @@
"seed": "pnpm --filter ./api seed",
"bones": "pnpm --filter ./web bones",
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
"start": "pnpm --filter ./web start",
"check:links": "pnpm --filter ./web check:links",
"typecheck": "pnpm -r typecheck",
"check:i18n": "pnpm --filter ./web check:i18n",
+1
View File
@@ -8,6 +8,7 @@
"og": "node scripts/og/build-og.mjs",
"og:fixtures": "node scripts/og/build-og.mjs --fixtures",
"build": "node scripts/og/build-og.mjs && astro build",
"start": "node server.mjs",
"preview": "astro preview",
"typecheck": "astro check",
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
+383
View File
@@ -0,0 +1,383 @@
/**
* The production web server.
*
* The site is `output: 'static'`: `pnpm build` prerenders every page and this process
* only hands the result out over HTTP. nginx sits in front of it and proxies, rather
* than pointing a `root` at the built tree, so that nothing outside this file decides
* what is readable. That is not a stylistic preference — it removes two whole classes
* of failure the file-serving arrangement kept producing:
*
* traversal permissions nginx runs as www-data and the build runs as cashumints,
* so every directory from / down to dist had to be traversable
* by a user with no other business in it. One 0700 home
* directory anywhere in the chain took the site down, and
* `try_files` reports a permission error as a plain miss, so
* the symptom was a blanket 404 or an internal-redirect loop
* ending in 500 — never the actual cause.
*
* duplicated routing the locale 404 rule lived in nginx as a hand-maintained
* alternation of 23 codes. Adding a language meant editing a
* file that is not in this repository, and forgetting to was
* silent. Locale 404s are resolved by looking in the built
* tree now, so the list cannot drift.
*
* Reading files is all it does. There is no template, no database handle and no route
* table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards
* them there directly.
*
* Env:
* SITE_PORT 8789 port to listen on
* SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS
* WEB_ROOT ./dist the tree to serve
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function int(raw, fallback) {
const n = Number.parseInt(raw ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/**
* The tree to serve, absolute.
*
* Defaults to the build output next to this file, which is what `pnpm start` and the
* tests use. In production it points at a copy outside the checkout: `pnpm build`
* empties dist before it writes, so serving dist directly means a rebuild takes the
* whole site down for the length of the build. See cashumints-web.service.
*/
export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist'));
const PORT = int(process.env.SITE_PORT, 8789);
const HOST = process.env.SITE_HOST ?? '127.0.0.1';
/** One line per event, key=value after the message. Matches the API's log format. */
function log(level, msg, fields = {}) {
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
}
const line = parts.join(' ');
if (level === 'error') console.error(line);
else console.log(line);
}
const TYPES = new Map(Object.entries({
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.wasm': 'application/wasm',
}));
const IMMUTABLE = 'public, max-age=31536000, immutable';
const WEEK = 'public, max-age=604800';
const HOUR = 'public, max-age=3600';
/** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */
const REVALIDATE = 'public, max-age=0, must-revalidate';
/**
* How long a response may be reused.
*
* Everything Vite and the card builder emit carries a content hash in its filename, so
* those are immutable for a year — a changed file is a changed URL. Markup is the
* opposite: the URLs are permanent and the bytes change on every rebuild, so it
* revalidates every time and the ETag below turns that into a 304 in the usual case.
*
* `/og/default.png` is the one unhashed card, served for pages that have no mint of
* their own, so it gets an hour rather than a year.
*/
export function cacheControl(urlPath, ext) {
if (ext === '.html') return REVALIDATE;
if (urlPath === '/og/default.png') return HOUR;
if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE;
if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR;
return WEEK;
}
/**
* Turn a request path into a path inside ROOT, or null if it escapes or is malformed.
*
* Returns the *relative* path so the caller can join it against ROOT; every segment is
* checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are
* refused outright: a static build emits none, so a request for one is either a probe
* or a mistake, and neither should be answered with bytes.
*/
export function safePath(urlPath) {
if (urlPath.includes('\0')) return null;
const normalized = path.posix.normalize(urlPath);
if (!normalized.startsWith('/')) return null;
const segments = normalized.split('/').filter(Boolean);
for (const segment of segments) {
if (segment === '..' || segment.startsWith('.')) return null;
}
return segments;
}
async function statFile(file) {
try {
const stats = await fsp.stat(file);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
/**
* The candidates for one request path, in order.
*
* Astro emits directory-style routes — /mints is dist/mints/index.html — and
* `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the
* literal path first keeps assets a single stat; the `.html` candidate covers the flat
* files at the root, /404.html among them.
*/
export function candidates(segments) {
const rel = segments.join('/');
if (rel === '') return ['index.html'];
return [rel, `${rel}/index.html`, `${rel}.html`];
}
/**
* The 404 body for a path, and it is not always the English one.
*
* A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into
* English, which is the same reason `redirectToDefaultLocale` is false in the Astro
* config. Which prefixes count is decided by what the build actually emitted: if
* <prefix>/404/index.html exists, the prefix is a locale. Nothing to keep in sync.
*/
async function notFoundBody(segments) {
const first = segments[0];
if (first) {
const localized = path.join(ROOT, first, '404', 'index.html');
const stats = await statFile(localized);
if (stats) return { file: localized, stats };
}
const fallback = path.join(ROOT, '404.html');
const stats = await statFile(fallback);
return stats ? { file: fallback, stats } : null;
}
/** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */
function etagFor(stats) {
return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`;
}
/** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */
function etagMatches(header, etag) {
if (!header) return false;
if (header.trim() === '*') return true;
const bare = etag.replace(/^W\//, '');
return header
.split(',')
.map((candidate) => candidate.trim().replace(/^W\//, ''))
.includes(bare);
}
function notModified(req, etag, lastModified) {
if (etagMatches(req.headers['if-none-match'], etag)) return true;
// Only consulted when the client sent no ETag, per RFC 9110 §13.1.3.
if (req.headers['if-none-match']) return false;
const since = Date.parse(req.headers['if-modified-since'] ?? '');
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
}
/**
* Headers every response carries.
*
* No CSP here on purpose. The site loads an analytics script from another origin, the
* review islands open websockets to whatever relays are configured and the status
* islands fetch whatever mint URLs the index holds, so a policy tight enough to be
* worth having has to be derived from those lists rather than guessed at — and a wrong
* one fails as a silently broken island. HSTS belongs to nginx, which is what actually
* terminates TLS.
*/
function baseHeaders() {
return {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
};
}
function send(res, status, headers, body) {
res.writeHead(status, { ...baseHeaders(), ...headers });
res.end(body);
}
/** Stream a file, or just its headers for HEAD. */
function sendFile(req, res, status, file, stats, urlPath) {
const ext = path.extname(file).toLowerCase();
const etag = etagFor(stats);
const headers = {
'Content-Type': TYPES.get(ext) ?? 'application/octet-stream',
'Content-Length': stats.size,
'Last-Modified': new Date(stats.mtimeMs).toUTCString(),
ETag: etag,
'Cache-Control': cacheControl(urlPath, ext),
...baseHeaders(),
};
// A 304 must not carry a body or a Content-Length describing one.
if (status === 200 && notModified(req, etag, stats.mtimeMs)) {
delete headers['Content-Length'];
delete headers['Content-Type'];
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(status, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = fs.createReadStream(file);
stream.on('error', (err) => {
log('error', 'read failed', { file, err: err.message });
res.destroy();
});
// Kill the read when the client hangs up mid-transfer rather than draining the file.
res.on('close', () => stream.destroy());
stream.pipe(res);
}
async function handle(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n');
return;
}
let urlPath;
try {
urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
const segments = safePath(urlPath);
if (!segments) {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
for (const candidate of candidates(segments)) {
const file = path.join(ROOT, candidate);
// Belt and braces: safePath already refused `..`, this refuses anything that still
// resolved outside the tree, a symlink in the build output included.
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break;
const stats = await statFile(file);
if (stats) {
sendFile(req, res, 200, file, stats, urlPath);
return;
}
}
const miss = await notFoundBody(segments);
if (!miss) {
send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n');
return;
}
// Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd
// URL indexed as a real page.
sendFile(req, res, 404, miss.file, miss.stats, urlPath);
}
export function createServer() {
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
log('error', 'request failed', { path: req.url, err: err.message });
if (!res.headersSent) {
send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n');
} else {
res.destroy();
}
});
});
/*
* Longer than nginx's upstream keepalive, and headersTimeout longer still.
*
* If this end closes an idle connection at the same moment nginx reuses it, nginx has
* nothing to retry and reports 502. Outlasting the proxy makes the proxy always the
* one to close, which is the race-free direction.
*/
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
// A malformed request line should not take the process with it.
server.on('clientError', (err, socket) => {
if (err.code === 'ECONNRESET' || !socket.writable) return;
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
return server;
}
/**
* Refuse to start on an empty or unreadable root.
*
* The failure this prevents is the one that is hardest to see: a process that starts
* cleanly, answers every request with a 404 and looks healthy to anything watching the
* port. Exiting non-zero puts the reason in `systemctl status` instead.
*/
async function checkRoot() {
const index = path.join(ROOT, 'index.html');
if (await statFile(index)) return;
log('error', 'web root has no index.html', { root: ROOT, hint: 'run pnpm build, then publish it to WEB_ROOT' });
process.exit(1);
}
/** Only when run directly, so the tests can import the pieces above. */
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await checkRoot();
const server = createServer();
server.listen(PORT, HOST, () => {
log('info', 'site listening', { host: HOST, port: PORT, root: ROOT });
});
let shuttingDown = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (shuttingDown) return;
shuttingDown = true;
log('info', 'shutting down', { signal });
server.close(() => process.exit(0));
// Idle keep-alive connections would otherwise hold the close open for a minute.
server.closeIdleConnections();
setTimeout(() => {
server.closeAllConnections();
process.exit(0);
}, 10_000).unref();
});
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* What the production server has to get right that a static file server does not.
*
* The site was served by nginx pointing a `root` at dist until this process took over,
* and the rules that lived in that config are the ones worth pinning down here: a miss
* has to answer 404 rather than 200 with a 404-shaped page, a miss under a locale has
* to stay in that locale, hashed assets have to be immutable and markup must not be,
* and nothing outside the root may be readable however the path is spelled.
*
* A fixture tree rather than dist/: these are assertions about the server, and running
* them should not require a build.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cashumints-web-'));
const write = async (rel, body) => {
await fs.mkdir(path.join(root, path.dirname(rel)), { recursive: true });
await fs.writeFile(path.join(root, rel), body);
};
await write('index.html', '<!doctype html><html lang="en">home</html>');
await write('404.html', '<!doctype html><html lang="en">missing</html>');
await write('es/404/index.html', '<!doctype html><html lang="es">no encontrado</html>');
await write('es/mints/index.html', '<!doctype html><html lang="es">casas</html>');
await write('mints/index.html', '<!doctype html><html lang="en">mints</html>');
await write('robots.txt', 'User-agent: *\n');
await write('_astro/app.abc123.js', 'export default 1;\n');
await write('og/default.png', 'not really a png');
await write('og/mint.abc123.png', 'not really a png either');
// ROOT is read from the environment once, at import.
process.env.WEB_ROOT = root;
const { createServer, safePath, candidates, cacheControl } = await import('../server.mjs');
const server = createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
test.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const get = (p, init) => fetch(`${base}${p}`, init);
test('serves the index at the root', async () => {
const res = await get('/');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'text/html; charset=utf-8');
assert.match(await res.text(), /home/);
});
test('resolves directory routes with and without a trailing slash', async () => {
for (const p of ['/mints', '/mints/']) {
const res = await get(p);
assert.equal(res.status, 200, p);
assert.match(await res.text(), /mints/);
}
});
test('a miss is a 404, not a 200 carrying the 404 page', async () => {
const res = await get('/nope');
assert.equal(res.status, 404);
assert.match(await res.text(), /missing/);
});
test('a miss under a locale stays in that locale', async () => {
const res = await get('/es/nope');
assert.equal(res.status, 404);
assert.match(await res.text(), /no encontrado/);
});
test('a miss under a prefix that is not a locale falls back to English', async () => {
const res = await get('/mint/does-not-exist');
assert.equal(res.status, 404);
assert.match(await res.text(), /missing/);
});
test('hashed assets are immutable and markup is not', async () => {
const asset = await get('/_astro/app.abc123.js');
assert.equal(asset.headers.get('cache-control'), 'public, max-age=31536000, immutable');
const page = await get('/');
assert.equal(page.headers.get('cache-control'), 'public, max-age=0, must-revalidate');
});
test('the one unhashed card is not cached for a year', async () => {
const shared = await get('/og/default.png');
assert.equal(shared.headers.get('cache-control'), 'public, max-age=3600');
const hashed = await get('/og/mint.abc123.png');
assert.equal(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
});
test('a matching ETag revalidates into a bodiless 304', async () => {
const first = await get('/');
const etag = first.headers.get('etag');
assert.ok(etag);
const second = await get('/', { headers: { 'If-None-Match': etag } });
assert.equal(second.status, 304);
assert.equal(await second.text(), '');
});
test('HEAD answers with the headers and no body', async () => {
const res = await get('/', { method: 'HEAD' });
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-length'), String((await fs.stat(path.join(root, 'index.html'))).size));
assert.equal(await res.text(), '');
});
test('anything but GET and HEAD is refused', async () => {
const res = await get('/', { method: 'POST' });
assert.equal(res.status, 405);
assert.equal(res.headers.get('allow'), 'GET, HEAD');
});
test('nothing outside the root is readable', async () => {
await fs.writeFile(path.join(root, '..', 'cashumints-secret.txt'), 'secret');
for (const p of ['/../cashumints-secret.txt', '/%2e%2e/cashumints-secret.txt', '/mints/../../cashumints-secret.txt']) {
const res = await get(p);
assert.equal(res.status, 404, p);
assert.doesNotMatch(await res.text(), /secret/, p);
}
await fs.rm(path.join(root, '..', 'cashumints-secret.txt'), { force: true });
});
test('dotfiles are refused rather than looked up', async () => {
const res = await get('/.env');
assert.equal(res.status, 400);
});
test('every response carries the baseline security headers', async () => {
const res = await get('/');
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
assert.equal(res.headers.get('referrer-policy'), 'strict-origin-when-cross-origin');
assert.equal(res.headers.get('x-frame-options'), 'DENY');
});
test('safePath refuses what it should and keeps what it should', () => {
assert.deepEqual(safePath('/mints/'), ['mints']);
assert.deepEqual(safePath('/'), []);
assert.equal(safePath('/a\0b'), null);
assert.equal(safePath('/.git/config'), null);
// Normalised away rather than escaping: the lookup stays inside the root.
assert.deepEqual(safePath('/../../etc/passwd'), ['etc', 'passwd']);
});
test('candidates cover the shapes a static build emits', () => {
assert.deepEqual(candidates([]), ['index.html']);
assert.deepEqual(candidates(['mints']), ['mints', 'mints/index.html', 'mints.html']);
});
test('cacheControl is decided by path and extension', () => {
assert.match(cacheControl('/mints', '.html'), /must-revalidate/);
assert.match(cacheControl('/_astro/x.js', '.js'), /immutable/);
assert.match(cacheControl('/robots.txt', '.txt'), /max-age=3600/);
assert.match(cacheControl('/favicon.ico', '.ico'), /max-age=604800/);
});