Compare commits

...
9 Commits
Author SHA1 Message Date
thalladaandClaude Fable 5.1 f634f06bf1 Settle on a five-minute public cache and drop the purge rationale
The edge-purge integration was reverted in the previous commit; this
finishes the job. Public pages, the feed and /issues.json now say
`public, max-age=300` for browsers and shared caches alike, the code
comments and README explain why the longer s-maxage plus purge design
was dropped, and the rollout runbook loses its purge step, marks the
HTML cache rule optional, and warns to keep the record DNS-only until
the Universal SSL certificate is active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 22:04:07 +00:00
thallada de332cd4b3 Revert "Add a [cdn] section and purge the edge after publishing"
This reverts commit 41fe61a691.
2026-09-04 22:00:06 +00:00
thalladaandClaude Fable 5.1 e616638c36 Document the CDN rollout and refresh the reverse-proxy section
The README's nginx snippet had drifted from the box: it was missing http2, the
security-headers include, the gzip block, the keepalive bump and the trusted
certificate. Replace it with what actually runs, then add the two CDN changes.

`set_real_ip_from` for Cloudflare's ranges plus `real_ip_header
CF-Connecting-IP` has to come first, or every request looks like it came from
Cloudflare and the login throttle becomes global. Then `X-Forwarded-For` is
*set* from `$remote_addr` rather than appended to: the throttle keys on the
first entry, so a client-supplied header must never survive into the app.
The ranges are listed for reference but the reader is told to regenerate them
from cloudflare.com/ips-v4 and /ips-v6, with a one-liner that does it.

The gzip comment claimed the stylesheet carries 445 KB of base64 fonts; it has
not since the faces moved back out to their own URLs.

The new runbook covers the order that matters: the DNS move (Free plan means a
full nameserver change, so the rest of the zone has to survive it), the zone
settings that are quietly wrong by default — Browser Cache TTL *raises* the
origin's max-age unless set to "Respect Existing Headers", and Rocket Loader and
Email Obfuscation both inject scripts the site's `script-src 'self'` CSP blocks
— the two cache rules, locking the origin to Cloudflare, wiring the purge token,
and the `cf-cache-status` checks that prove each row of the header matrix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 21:29:10 +00:00
thalladaandClaude Fable 5.1 7d12f53314 Make the origin's Cache-Control headers CDN-safe
An audit of the live site ahead of the Cloudflare move found three ways a
cache-everything rule would have gone wrong.

First, several routes named no policy at all — `/login`, `/issues.json`,
`/files/epub/*`, `/files/xtc/*`, `/robots.txt`, the error pages — so the edge
would have applied its own default TTL (Cloudflare: two hours on a 200). For
`/files/*` that means an authenticated download becoming a publicly cached one.
Those routes now say what they mean, and `security_headers` fails closed: a
response that set no `Cache-Control` gets `no-store`, so a route added later
cannot silently inherit the CDN's default. Everything cookie- or Basic-auth
gated (`/files/*`, `/opds*`) is `private, no-store` on every response, 401s and
404s included.

Second, the public pages said `max-age=300` alone. They now say
`public, max-age=300, s-maxage=86400`: five minutes for the browser, a day for
the edge, which is safe because publishing purges the edge. A request carrying
a `daily_session=` cookie still gets `private, no-store`.

Third, `/static/favicon.svg` and `/static/speculation.json` were referenced
without `?v=` while being served `immutable` for a year — editing either one
could never have reached a browser again. Both are now in the `ASSET_VERSION`
hash and referenced with the version, and `static_asset` only promises a year
when the URL actually carries `?v=`; a bare `/static/…` URL gets an hour and
revalidates against the same ETag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 21:28:59 +00:00
thalladaandClaude Fable 5.1 41fe61a691 Add a [cdn] section and purge the edge after publishing
The site is about to sit behind Cloudflare, where the public pages will be
allowed to live at the edge for a day (`s-maxage`, next commit). That is only
correct if the edge is emptied the moment a new issue lands, so the origin now
does the emptying itself rather than leaving it to an operator to remember.

`[cdn]` is inert by default: with no `provider` nothing is called and no token
is needed, so an origin with no CDN behaves exactly as before. Setting
`provider = "cloudflare"` without both a zone id and `DAILY_EPUB_CDN__API_TOKEN`
is a config error — a half-configured purge would publish into a stale edge and
say nothing.

The purge is `purge_everything` on purpose. A new issue changes more than its
own page: `/`, `/issues`, `/feed.xml`, `/issues.json`, and the previous issue's
page too, whose "latest" nav marker moves. A per-URL list of that set is exactly
the kind of thing that silently rots, and everything expensive at the edge is
content-hashed, so refilling it costs one origin fetch.

A purge failure is logged at warn and never fails the run: the paper is already
published and recorded by then, and a few stale hours are not worth failing over.
`daily-epub cdn purge` runs the same code by hand; it takes no run lock because
it touches neither the database nor the publish directories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 21:28:48 +00:00
thalladaandClaude Fable 5.1 2388d7bd02 Drop the font preload and defer app.js
Measured on a quiet machine behind a gzip proxy, three interleaved rounds
per variant: with the preload FCP 1.8 s, LCP 2.6 s, perf 0.95-0.96;
without it FCP 0.97 s, LCP 0.97 s, perf 1.00.

Lantern splits bandwidth between in-flight requests, so preloading the
132 KB regular face starved the 12 KB render-blocking stylesheet — and
the stylesheet, not the font, is what first paint waits on. The
metric-matched fallbacks already make the wait for the real face
invisible, so the preload bought nothing that first paint could see.

app.js gets `defer` for the same reason: Lighthouse counts the sync tag
among the render-blocking requests. It stays at the end of body, and it
is plain top-level DOM code with no readyState or DOMContentLoaded
dependence, so execution order is unchanged.

theme.js keeps its place: still synchronous, still after the stylesheet
link, so Gecko cannot paint unstyled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 20:08:42 +00:00
thalladaandClaude Fable 5.1 62803d73a6 Merge branches psi-fonts and psi-a11y-seo: Lighthouse fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 20:02:13 +00:00
thalladaandClaude Fable 5.1 4c8eeda147 Stop inlining the Newsreader faces into the render-blocking stylesheet
/static/app.css was 305 KB because APP_CSS base64-embedded both woff2
faces into it at runtime, and the browser cannot paint until it has the
whole sheet. Lighthouse mobile put FCP and LCP at 2.4 s for it.

The inlining was justified by a Firefox font-flash theory that the later
FOUC investigation (239125a) disproved: the flash came from theme.js
running ahead of the stylesheet, not from fonts arriving late. So point
the faces back at /static/Newsreader*.woff2, versioned with ASSET_VERSION
so the immutable one-year max-age stays safe. Gzipped, the sheet goes
from 304 KB to 12 KB.

That leaves first paint waiting on a font, so switch both faces from
font-display: block to swap and give them metric-matched local fallbacks
(Georgia, Times New Roman, Noto Serif, DejaVu Serif). The overrides are
computed from Newsreader's own metrics at the body optical size against
@capsizecss/metrics widths, so the fallback occupies the same box and the
swap changes glyph shapes and nothing else: every block on the page lands
at the same y-position either way, and CLS stays at 0.001.

Preload the regular face so it starts with the stylesheet rather than
after it parses; italic stays on demand. theme.js keeps its place after
the stylesheet link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 19:58:28 +00:00
thalladaandClaude Fable 5.1 04bf14f710 Fix the two Lighthouse findings: muted contrast and meta description
Accessibility (95 -> 100). Light `--muted` #7a7568 was 4.14:1 on `--paper`
and 3.72:1 on `--paper-2`, under the 4.5:1 AA floor for normal text — and
muted is the ears line, the nav, bylines, the footer, table headers and
placeholders, so it was most of the page's small type. An audit of every
text token against both surfaces turned up one more: light `--warn`
#8a6d1f at 4.42 / 3.97.

Both are darkened just enough to clear 4.5 on the darker of the two
surfaces, at constant hue and saturation, so the warm grey and the ochre
read the same:

  --muted  #7a7568 -> #6b665a   4.14/3.72 -> 5.16/4.63
  --warn   #8a6d1f -> #7d631c   4.42/3.97 -> 5.16/4.63

Everything else already passed on both surfaces (light ink 15.54/13.96,
ink-2 7.83/7.03, accent 6.73/6.04, loved 5.44/4.89, good 5.29/4.76, down
5.96/5.36), as did the whole dark set against #151513 / #1e1d1a (muted
5.31/4.90 is its floor), so the dark blocks are untouched and stay
identical to each other. `.badge` tints sit on near-paper, so the new
token values carry it.

SEO (91 -> 100). The layout had no `<meta name="description">`. `Page`
now carries one — `DEFAULT_DESCRIPTION` for the site, so all ~26
`Page::new` callers keep their signature — with `with_description` for
the pages worth writing one for: the issue page (issue number, date and
the counts the masthead already prints), the archive, the empty-latest
landing page and sign-in. Dashboard pages keep the default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 19:43:56 +00:00
10 changed files with 860 additions and 74 deletions

No files matched your search

+110 -11
View File
@@ -464,27 +464,63 @@ Node for the XTC converter, whose JIT needs W+X pages.
The rating links baked into every article chapter point at The rating links baked into every article chapter point at
`server.public_url`, so `daily.hallada.net` must resolve and serve TLS from the `server.public_url`, so `daily.hallada.net` must resolve and serve TLS from the
internet (e-readers tap these links). The OPDS catalog rides on the same host. internet (e-readers tap these links). The OPDS catalog rides on the same host.
The login throttle's `SmartIpKeyExtractor` trusts `X-Forwarded-For`; this is
safe only because the configured bind address is loopback and nginx is the The login throttle's `SmartIpKeyExtractor` keys on the first entry of
only process that can reach it. `X-Forwarded-For`, so nginx must *set* that header from the connection's peer
With an existing certificate, a minimal nginx site is: address rather than appending to whatever the client sent — otherwise anyone
can mint a fresh throttle bucket per attempt by sending their own header. With
Cloudflare in front, the connection's peer is Cloudflare, so `real_ip` has to
rewrite `$remote_addr` from `CF-Connecting-IP` first (see the snippet below).
The whole arrangement is only safe because the configured bind address is
loopback and nginx is the only process that can reach it.
The site is served through Cloudflare; `docs/runbooks/cdn-rollout.md` covers the
zone setup and cache rules. The nginx side of it is the `set_real_ip_from`
snippet and the `X-Forwarded-For` line below.
```nginx ```nginx
server { server {
listen 443 ssl; listen 443 ssl http2;
listen [::]:443 ssl; listen [::]:443 ssl http2;
server_name daily.hallada.net; server_name daily.hallada.net;
ssl_certificate /etc/letsencrypt/live/daily.hallada.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/daily.hallada.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/daily.hallada.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/daily.hallada.net/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/daily.hallada.net/fullchain.pem;
include /etc/nginx/snippets/security-headers.conf;
# Trust Cloudflare's edge for the client address: $remote_addr becomes the
# visitor, not the proxy. Must come before the X-Forwarded-For line below.
include /etc/nginx/snippets/cloudflare-real-ip.conf;
# The global `gzip on` only covers text/html. The stylesheet is ~12 KB of
# plain CSS again — the Newsreader faces are separate .woff2 URLs, not
# base64 inside it (see the doc comment on `web::APP_CSS`) — so this list is
# about the JSON, XML and SVG responses. woff2 is already compressed and is
# deliberately absent.
gzip_types text/css application/javascript application/json
application/speculationrules+json image/svg+xml
application/atom+xml application/xml text/plain;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
# Keep a browser's connection open longer than the 75 s default so the
# first click after a pause reuses it instead of paying a new TLS handshake.
keepalive_timeout 300s;
# XTCH files can be tens of MB; don't buffer them through nginx memory. # XTCH files can be tens of MB; don't buffer them through nginx memory.
proxy_max_temp_file_size 0; proxy_max_temp_file_size 0;
location / { location / {
proxy_pass http://127.0.0.1:3499; proxy_pass http://127.0.0.1:3499;
proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # SET, not append. The login throttle keys on the first X-Forwarded-For
# entry, so a client-supplied header must never survive into the app.
# $remote_addr is the real visitor because of the real_ip snippet above.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
@@ -497,12 +533,75 @@ server {
} }
``` ```
Enable it (`ln -s` into `sites-enabled`, `nginx -t`, `systemctl reload nginx`), `/etc/nginx/snippets/cloudflare-real-ip.conf` is one `set_real_ip_from` line per
then `curl https://daily.hallada.net/healthz` should return `ok`. No auth is published Cloudflare range plus the header to read. Cloudflare adds ranges from
needed at the proxy layer: rating links are self-authenticating (HMAC tokens) time to time, so **regenerate it from the source rather than copying this list**
and the OPDS/file routes use the app-level Basic auth from (<https://www.cloudflare.com/ips-v4>, <https://www.cloudflare.com/ips-v6>):
```sh
{ curl -s https://www.cloudflare.com/ips-v4; echo; \
curl -s https://www.cloudflare.com/ips-v6; echo; } \
| awk 'NF {print "set_real_ip_from " $0 ";"} END {print "real_ip_header CF-Connecting-IP;"}' \
| sudo tee /etc/nginx/snippets/cloudflare-real-ip.conf
```
As of this writing that produces:
```nginx
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;
```
Enable the site (`ln -s` into `sites-enabled`, `nginx -t`, `systemctl reload
nginx`), then `curl https://daily.hallada.net/healthz` should return `ok`. No
auth is needed at the proxy layer: rating links are self-authenticating (HMAC
tokens) and the OPDS/file routes use the app-level Basic auth from
`server.basic_auth_user`/`_pass` if you set them. `server.basic_auth_user`/`_pass` if you set them.
#### HTTP caching
The origin says what may be cached and for how long; the CDN is configured to
respect it (runbook §2). The whole matrix:
| route | `Cache-Control` |
|---|---|
| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300` |
| the same pages with a `daily_session=` cookie | `private, no-store` |
| `/robots.txt` | `public, max-age=86400` |
| `/static/*?v=<hash>` | `public, max-age=31536000, immutable` |
| `/static/*` with no `?v=` | `public, max-age=3600` |
| `/files/epub/*`, `/files/xtc/*`, `/opds*` (every status) | `private, no-store` |
| `/dashboard*`, `/login`, `/account`, errors, anything else | `no-store` |
Five minutes is the whole freshness story: an issue changes once a day, a
reader's tab or a CDN edge revalidates within five minutes of the new one
landing, and signed-in requests never touch a shared cache at all. A longer
edge age with an API purge after each publish was considered and dropped as
not worth its moving parts. The `no-store` on the last row is a default applied
by the `security_headers` middleware to any response that set no policy of its
own, so a route added later cannot silently inherit the CDN's default TTL.
### Installing the XTC converter ### Installing the XTC converter
```sh ```sh
+244
View File
@@ -0,0 +1,244 @@
# Runbook — putting daily.hallada.net behind Cloudflare
**Written:** 2026-09-04 for the production host. Steps 1–3 happen in the Cloudflare
and registrar dashboards; steps 4–5 are on the server as an operator with `sudo`.
The origin-side changes (the `Cache-Control` matrix below) ship in the same
release; deploy the new binary before enabling the proxy.
Read the whole thing before starting step 1: a nameserver change is the one step
that cannot be undone in seconds.
## 0. What the origin already does
The app decides what is cacheable; Cloudflare is configured only to obey it.
| route | `Cache-Control` |
|---|---|
| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300` |
| the same pages with a `daily_session=` cookie | `private, no-store` |
| `/robots.txt` | `public, max-age=86400` |
| `/static/*?v=<hash>` | `public, max-age=31536000, immutable` |
| `/static/*` with no `?v=` | `public, max-age=3600` |
| `/files/epub/*`, `/files/xtc/*`, `/opds*` (every status, 401 and 404 included) | `private, no-store` |
| `/dashboard*`, `/login`, `/account`, error pages, anything else | `no-store` |
Two facts follow from that table and matter for every choice below:
- A public page is fresh for **five minutes** everywhere: browser and edge alike
revalidate within five minutes of a new issue landing, and nothing has to tell
the edge that the day changed. (A one-day edge age with an API purge after
every publish was tried and dropped: at this traffic an edge rarely keeps a
page long enough for the longer age to matter, and the purge was one more
token, config section and failure mode.)
- Anything gated by a cookie or Basic auth already says `private, no-store`, so
even a misconfigured cache-everything rule cannot make a download public. The
cookie bypass rule in step 3 is defence in depth, not the only defence.
## 1. Move DNS to Cloudflare
The Free plan has no partial (CNAME) setup, so this is a full nameserver move:
Cloudflare becomes authoritative for the entire `hallada.net` zone, not just this
host. Everything else in the zone must survive the move unchanged.
1. Export the current zone from Route 53 as BIND:
```sh
ZONE=$(aws route53 list-hosted-zones-by-name --dns-name hallada.net. \
--query 'HostedZones[0].Id' --output text | cut -d/ -f3)
aws route53 list-resource-record-sets --hosted-zone-id "$ZONE" > /tmp/hallada-net.json
```
The AWS CLI has no BIND exporter; the console's **Export zone file** button on
the hosted zone does produce one. Keep both files — the JSON is the rollback
reference.
2. In Cloudflare, **Add a site** → `hallada.net` → Free plan → **Import DNS
records** and upload the BIND file. Then read the imported list against the
export line by line. Pay attention to `MX`, `TXT` (SPF/DKIM/DMARC), and any
`CNAME` used for verification: those must all stay **DNS only** (grey cloud).
3. Set **only** the `daily` record to **Proxied** (orange cloud). Nothing else in
the zone should be proxied — proxying an MX target or a mail-related host
breaks it.
4. Change the nameservers at the registrar to the two Cloudflare assigns.
Propagation is usually minutes and can be hours. Cloudflare emails when the
zone goes active. **Keep `daily` DNS-only until the zone is Active *and*
SSL/TLS → Edge Certificates shows the Universal certificate as Active.** A
proxied host with no edge certificate yet fails every TLS handshake
(`SSL alert number 40`) while plain HTTP still redirects, which is exactly
what happened on the first attempt: readers whose resolvers had picked up
the new nameservers got a broken site. Set Full (strict) (step 2) before
flipping the record to Proxied.
5. Before the switch, drop the TTL on the `daily` record (60 s) so a rollback is
quick. Verify afterwards:
```sh
dig +short NS hallada.net
dig +short daily.hallada.net # Cloudflare anycast addresses once proxied
dig +short MX hallada.net # unchanged
```
## 2. Zone settings
Under the zone's **SSL/TLS**, **Speed** and **Scrape Shield** sections:
- **SSL/TLS → Overview → Full (strict)**. The origin has a real Let's Encrypt
certificate, so there is no reason to accept anything weaker. *Flexible* would
make Cloudflare talk plain HTTP to the origin and would break the redirect in
the nginx `:80` server block into a loop.
- **Caching → Configuration → Browser Cache TTL → Respect Existing Headers.**
This one is easy to miss and quietly wrong: the default (**4 hours**) *raises*
the `max-age` Cloudflare sends to browsers, so the deliberate 5-minute browser
age on the public pages would become 4 hours and a reader's tab would show a
stale paper for most of a morning.
- **Speed → Optimization → Rocket Loader: OFF.**
- **Scrape Shield → Email Address Obfuscation: OFF.**
Both inject a Cloudflare-hosted script into the HTML. The site's CSP is
`script-src 'self'`, so the browser blocks the injected script and the page
either breaks or logs CSP violations on every load. There is nothing to gain:
the site has no email addresses in its markup and its own JS is two small
files.
- **Do not enable Web Analytics with automatic injection** — same reason, it is
an injected third-party script. If analytics are wanted later, they have to be
first-party and listed in the CSP.
- **Always Use HTTPS: ON** is fine and lets the origin's `:80` block stay as a
backstop.
## 3. Cache Rules
**Caching → Cache Rules.** Order matters: rule 1 must sit above rule 2.
**Rule 1 — "Bypass cache for signed-in readers"**
- When incoming requests match: `http.cookie contains "daily_session="`
- Then: **Bypass cache**
The origin already sends `private, no-store` to a cookie-bearing request, but
this makes the bypass a property of the request rather than of the response, so
nothing is ever *looked up* in a shared cache for a signed-in reader.
**Rule 2 — "Cache by origin headers"** *(optional)*
Without this rule Cloudflare caches only its default static extensions (the
CSS, JS, fonts and SVG here) and passes HTML straight through, which already
captures most of the benefit: TLS terminates at the edge and the origin is
reached over a warm connection. Add the rule if you want the HTML itself
served from the edge; it is only safe together with rule 1.
- When incoming requests match: `http.host eq "daily.hallada.net"`
- Then: **Eligible for cache**
- **Edge TTL:** *Use cache-control header if present, bypass cache if not*
- **Browser TTL:** *Respect origin*
That Edge TTL mode is the whole point of the origin work: a response with
`max-age` is cached for exactly that long, and a response with `no-store` (or
one that somehow arrives with no policy at all) is not cached. It is the reason
the `security_headers` middleware defaults unknown routes to `no-store` — with
this mode, "no header" means "do not cache" rather than "cache for 2 hours".
Leave **Cache Key** at its default. Do not add "Ignore query string": the
`?v=<hash>` on `/static/*` is what makes the immutable one-year `max-age` safe.
## 4. Lock the origin to Cloudflare
Once traffic arrives through the edge, direct hits on the origin's port 443
should stop. Either is enough; the first is simpler.
- **Firewall:** allow 443 only from the published Cloudflare ranges.
```sh
{ curl -s https://www.cloudflare.com/ips-v4; echo; \
curl -s https://www.cloudflare.com/ips-v6; echo; } \
| awk 'NF {print $0}' \
| xargs -I{} sudo ufw allow proto tcp from {} to any port 443 comment 'cloudflare'
sudo ufw delete allow 443/tcp # remove the open rule last
```
Keep a way in that does not depend on this (SSH from your own address) before
removing the open rule.
- **Authenticated Origin Pulls** (SSL/TLS → Origin Server) instead: install
Cloudflare's client CA on the origin and add `ssl_client_certificate` +
`ssl_verify_client on` to the nginx server block. Stronger, but one more
certificate to keep track of.
Also apply the nginx changes from the README's *Reverse proxy* section now:
the `include /etc/nginx/snippets/cloudflare-real-ip.conf;` (generated from
Cloudflare's published ranges, `real_ip_header CF-Connecting-IP;` at the end) and
`proxy_set_header X-Forwarded-For $remote_addr;` in place of
`$proxy_add_x_forwarded_for`. Without the first, every request looks like it came
from Cloudflare and the login throttle becomes global; without the second, a
client could forge the throttle key. Then:
```sh
sudo nginx -t && sudo systemctl reload nginx
```
Check a login attempt from a phone on cellular and one from the LAN land in
different throttle buckets, and that `journalctl -u daily-epub` shows real client
addresses rather than Cloudflare's.
## 5. Verify
Anonymous public page — with rule 2, expect `HIT` on the second request (the
first fills the edge); without it, `DYNAMIC`. Either way the origin's own
`Cache-Control` must come through unchanged:
```sh
curl -sI https://daily.hallada.net/ | grep -iE 'cf-cache-status|cache-control|age'
curl -sI https://daily.hallada.net/ | grep -i cf-cache-status # HIT
```
Signed in — the cookie rule must take it out of the cache entirely:
```sh
curl -sI -H 'Cookie: daily_session=whatever' https://daily.hallada.net/ \
| grep -iE 'cf-cache-status|cache-control'
# cf-cache-status: BYPASS (DYNAMIC is also acceptable)
# cache-control: private, no-store
```
Static assets — versioned URLs should settle on `HIT`:
```sh
curl -s https://daily.hallada.net/ | grep -o '/static/app.css?v=[0-9a-f]*'
curl -sI 'https://daily.hallada.net/static/app.css?v=<hash>' \
| grep -iE 'cf-cache-status|cache-control'
# cf-cache-status: HIT
# cache-control: public, max-age=31536000, immutable
```
Downloads and OPDS — never cached, at any status:
```sh
curl -sI https://daily.hallada.net/opds | grep -iE 'cf-cache-status|cache-control'
curl -sI https://daily.hallada.net/files/epub/does-not-exist.epub \
| grep -iE 'cf-cache-status|cache-control'
# cache-control: private, no-store (cf-cache-status: BYPASS or DYNAMIC)
```
The dashboard and the login page:
```sh
curl -sI https://daily.hallada.net/login | grep -iE 'cf-cache-status|cache-control'
# cache-control: no-store
```
Finally, the morning after: within five minutes of the `generate` timer firing,
`curl -s https://daily.hallada.net/ | grep -o 'issues/[0-9-]*' | head -1` shows
the new date from a network that is not signed in.
## Rollback
- **Cache misbehaving:** turn on **Development Mode** (Caching → Configuration)
for a three-hour edge bypass while you look, or **Purge Everything**. Neither
needs a deploy.
- **Something worse:** set the `daily` DNS record back to **DNS only** (grey
cloud). Traffic goes straight to the origin again within the record's TTL, and
nothing about the origin's behaviour depends on Cloudflare being there — the
`Cache-Control` headers are correct without it.
- **Full retreat:** point the registrar's nameservers back at Route 53. The
hosted zone still exists unless it was deleted; do not delete it until the
Cloudflare setup has run for a few weeks.
+37 -6
View File
@@ -13,7 +13,10 @@
//! | `GET /healthz` | liveness | //! | `GET /healthz` | liveness |
//! | `GET /issues.json` | the last 30 run reports, newest first | //! | `GET /issues.json` | the last 30 run reports, newest first |
//! //!
//! `/opds/*` and `/files/*` sit behind optional Basic auth (`server.basic_auth_*`). //! `/opds/*` and `/files/*` sit behind optional Basic auth (`server.basic_auth_*`)
//! and are therefore `private, no-store` on every response, 401s and 404s
//! included: the site sits behind a CDN whose cache-everything rule would
//! otherwise turn an authenticated download into a public one (§3.12).
//! //!
//! The EPUB article footer (§3.10) mints its three verdict links with the very same //! The EPUB article footer (§3.10) mints its three verdict links with the very same
//! [`rating_url`] this module verifies with — both re-export [`crate::auth`], //! [`rating_url`] this module verifies with — both re-export [`crate::auth`],
@@ -44,6 +47,7 @@ use tower_http::trace::TraceLayer;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use crate::types::{ArticleId, RatingEvent, Vote}; use crate::types::{ArticleId, RatingEvent, Vote};
use crate::web::public::{PRIVATE_CACHE, PUBLIC_CACHE};
/// Characters of the hex HMAC kept in rating links (§3.9). /// Characters of the hex HMAC kept in rating links (§3.9).
pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN; pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN;
@@ -300,9 +304,13 @@ async fn handle_issues_json(State(state): State<AppState>) -> Response {
}) })
.collect(); .collect();
match serde_json::to_string_pretty(&issues) { match serde_json::to_string_pretty(&issues) {
// Only publishing changes this; five minutes of staleness is fine (§3.12).
Ok(body) => ( Ok(body) => (
StatusCode::OK, StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")], [
(header::CONTENT_TYPE, "application/json"),
(header::CACHE_CONTROL, PUBLIC_CACHE),
],
body, body,
) )
.into_response(), .into_response(),
@@ -406,7 +414,7 @@ async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Respo
StatusCode::OK, StatusCode::OK,
[ [
(header::CONTENT_TYPE, OPDS_CONTENT_TYPE), (header::CONTENT_TYPE, OPDS_CONTENT_TYPE),
(header::CACHE_CONTROL, "no-cache"), (header::CACHE_CONTROL, PRIVATE_CACHE),
], ],
feed, feed,
) )
@@ -415,6 +423,7 @@ async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Respo
tracing::error!(error = %e, "could not build the OPDS feed"); tracing::error!(error = %e, "could not build the OPDS feed");
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
[(header::CACHE_CONTROL, PRIVATE_CACHE)],
"could not build the feed", "could not build the feed",
) )
.into_response() .into_response()
@@ -465,7 +474,12 @@ async fn serve_file(
} }
let Some(path) = safe_join(dir, name) else { let Some(path) = safe_join(dir, name) else {
tracing::warn!(name, "rejected an unsafe file name"); tracing::warn!(name, "rejected an unsafe file name");
return (StatusCode::BAD_REQUEST, "bad file name").into_response(); return (
StatusCode::BAD_REQUEST,
[(header::CACHE_CONTROL, PRIVATE_CACHE)],
"bad file name",
)
.into_response();
}; };
// An XTCH issue is a pre-rendered page bitmap per page — ~100 MB for a full // An XTCH issue is a pre-rendered page bitmap per page — ~100 MB for a full
// day. Stream it rather than buffering the whole file per request (§3.11). // day. Stream it rather than buffering the whole file per request (§3.11).
@@ -476,7 +490,12 @@ async fn serve_file(
} }
Err(e) => { Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "file not found"); tracing::warn!(error = %e, path = %path.display(), "file not found");
return (StatusCode::NOT_FOUND, "not found").into_response(); return (
StatusCode::NOT_FOUND,
[(header::CACHE_CONTROL, PRIVATE_CACHE)],
"not found",
)
.into_response();
} }
}; };
// CrossPoint dispatches on the saved file's extension, not on this header, // CrossPoint dispatches on the saved file's extension, not on this header,
@@ -488,6 +507,12 @@ async fn serve_file(
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
// Downloads are cookie- or Basic-auth gated: a shared cache must never hold
// one, whatever cache rule the CDN in front of us is configured with (§3.12).
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(PRIVATE_CACHE),
);
if let Ok(value) = HeaderValue::from_str(&format!( if let Ok(value) = HeaderValue::from_str(&format!(
"attachment; filename=\"{}\"", "attachment; filename=\"{}\"",
name.replace('"', "") name.replace('"', "")
@@ -563,7 +588,13 @@ fn check_basic_auth(config: &Config, headers: &HeaderMap) -> Option<Response> {
Some( Some(
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, challenge)], [
(header::WWW_AUTHENTICATE, challenge),
(
header::CACHE_CONTROL,
HeaderValue::from_static(PRIVATE_CACHE),
),
],
"authentication required", "authentication required",
) )
.into_response(), .into_response(),
+16
View File
@@ -1703,6 +1703,10 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(archive.status(), StatusCode::OK); assert_eq!(archive.status(), StatusCode::OK);
assert_eq!(
archive.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
let feed = app let feed = app
.clone() .clone()
@@ -1718,6 +1722,10 @@ mod tests {
feed.headers().get(header::CONTENT_TYPE).unwrap(), feed.headers().get(header::CONTENT_TYPE).unwrap(),
"application/atom+xml; charset=utf-8" "application/atom+xml; charset=utf-8"
); );
assert_eq!(
feed.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
let feed = String::from_utf8( let feed = String::from_utf8(
to_bytes(feed.into_body(), 1024 * 1024) to_bytes(feed.into_body(), 1024 * 1024)
.await .await
@@ -1750,6 +1758,10 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(
robots.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=86400"
);
let robots = let robots =
String::from_utf8(to_bytes(robots.into_body(), 4096).await.unwrap().to_vec()).unwrap(); String::from_utf8(to_bytes(robots.into_body(), 4096).await.unwrap().to_vec()).unwrap();
assert!(robots.contains("Disallow: /dashboard")); assert!(robots.contains("Disallow: /dashboard"));
@@ -1763,6 +1775,10 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(
reports.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
);
let reports = let reports =
String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap(); String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap();
assert!(reports.contains("\"status\": \"ok\"")); assert!(reports.contains("\"status\": \"ok\""));
+316 -39
View File
@@ -197,47 +197,71 @@ pub struct Flash {
pub text: String, pub text: String,
} }
/// The site-wide `<meta name="description">`, used by every page that does not
/// set one of its own. Search engines truncate around 160 characters.
pub const DEFAULT_DESCRIPTION: &str = concat!(
"A daily newspaper of the web: articles hand-picked from one reader's feeds, ",
"published every morning as an EPUB and readable here."
);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Page { pub struct Page {
pub title: String, pub title: String,
/// The `<meta name="description">` for this page; `DEFAULT_DESCRIPTION`
/// unless a handler overrides it with [`Page::with_description`].
pub description: String,
pub viewer: Option<Viewer>, pub viewer: Option<Viewer>,
pub flash: Option<Flash>, pub flash: Option<Flash>,
pub active_nav: String, pub active_nav: String,
pub version: &'static str, pub version: &'static str,
/// Cache-busting token for `/static/*.css|js` URLs: a content hash, so /// Cache-busting token for every `/static/*` URL the site references: a
/// any stylesheet or script change reaches browsers that cached the /// content hash, so any asset change reaches browsers and edge caches that
/// previous build (they are served with an immutable one-year `max-age`). /// hold the previous build (a `?v=`-carrying `/static/*` URL is served with
/// an immutable one-year `max-age`).
pub asset_version: &'static str, pub asset_version: &'static str,
} }
/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
const NEWSREADER: &[u8] = include_bytes!("static/fonts/Newsreader.woff2"); const NEWSREADER: &[u8] = include_bytes!("static/fonts/Newsreader.woff2");
const NEWSREADER_ITALIC: &[u8] = include_bytes!("static/fonts/Newsreader-italic.woff2"); const NEWSREADER_ITALIC: &[u8] = include_bytes!("static/fonts/Newsreader-italic.woff2");
/// The stylesheet with both Newsreader faces embedded as `data:` URIs. /// The stylesheet with both Newsreader faces pointed at versioned URLs.
/// ///
/// A font referenced by URL is applied *after* first paint whenever the browser /// The faces used to be inlined here as `data:` URIs on the theory that a font
/// has to bring it back from the disk cache (Firefox drops fonts from memory as /// fetched by URL lands after first paint and flashes. That theory was wrong:
/// soon as no page uses them), which shows as a flash of invisible or fallback /// the flash of unstyled content came from script ordering (a parser-blocking
/// text on the first navigation after an idle spell. Fonts embedded in the /// `theme.js` ahead of the stylesheet let Gecko paint before the sheet applied,
/// render-blocking stylesheet are decoded synchronously, so the first paint is /// Bugzilla 1459305), and moving the script after the `<link>` fixed it. What
/// already set in Newsreader. /// the inlining did cost was real: it tripled the render-blocking stylesheet to
/// ~305 KB and pushed first paint out by more than a second on mobile.
///
/// So the URLs stay URLs, carrying `?v=<ASSET_VERSION>` so the immutable
/// one-year `max-age` on a versioned `/static/*` URL is safe across deploys.
/// The fonts are part of the `ASSET_VERSION` hash, so a new face mints a new
/// URL. Neither face is preloaded — that only takes bandwidth from this sheet,
/// which is what first paint actually waits on. Instead both use
/// `font-display: swap` behind metric-matched local fallbacks, so first paint
/// is immediate and the swap shifts nothing.
pub static APP_CSS: LazyLock<String> = LazyLock::new(|| { pub static APP_CSS: LazyLock<String> = LazyLock::new(|| {
use base64::Engine; let version = ASSET_VERSION.as_str();
let data_uri = |bytes: &[u8]| {
format!(
"url(data:font/woff2;base64,{})",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
};
include_str!("static/app.css") include_str!("static/app.css")
.replace("url(/static/Newsreader.woff2)", &data_uri(NEWSREADER)) .replace(
"url(/static/Newsreader.woff2)",
&format!("url(/static/Newsreader.woff2?v={version})"),
)
.replace( .replace(
"url(/static/Newsreader-italic.woff2)", "url(/static/Newsreader-italic.woff2)",
&data_uri(NEWSREADER_ITALIC), &format!("url(/static/Newsreader-italic.woff2?v={version})"),
) )
}); });
/// First 12 hex digits of the SHA-256 over *every* embedded static asset.
///
/// Every file listed here must also be referenced with `?v={ASSET_VERSION}`,
/// and every file referenced with `?v=` must be hashed here — the two halves
/// are what make the immutable one-year `max-age` safe. The favicon and the
/// speculation rules are in the hash for exactly that reason: they used to be
/// referenced by bare URL while still being served `immutable`, so editing
/// either one could never have reached a browser (or the CDN) again.
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| { pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(include_str!("static/app.css")); hasher.update(include_str!("static/app.css"));
@@ -245,13 +269,28 @@ pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
hasher.update(NEWSREADER_ITALIC); hasher.update(NEWSREADER_ITALIC);
hasher.update(include_str!("static/app.js")); hasher.update(include_str!("static/app.js"));
hasher.update(include_str!("static/theme.js")); hasher.update(include_str!("static/theme.js"));
hasher.update(include_str!("static/favicon.svg"));
hasher.update(include_str!("static/speculation.json"));
hex::encode(hasher.finalize())[..12].to_string() hex::encode(hasher.finalize())[..12].to_string()
}); });
/// The `Speculation-Rules` header value: a versioned URL, so a change to
/// `speculation.json` is picked up rather than pinned behind the immutable
/// one-year `max-age` on `/static/*`. Built once, like [`APP_CSS`], because it
/// interpolates [`ASSET_VERSION`] and so cannot be a `from_static`.
static SPECULATION_RULES: LazyLock<HeaderValue> = LazyLock::new(|| {
HeaderValue::from_str(&format!(
"\"/static/speculation.json?v={}\"",
ASSET_VERSION.as_str()
))
.expect("the asset version is hex, so the header value is valid")
});
impl Page { impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self { pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self { Self {
title: title.into(), title: title.into(),
description: DEFAULT_DESCRIPTION.to_string(),
viewer, viewer,
flash: None, flash: None,
active_nav: active_nav.to_string(), active_nav: active_nav.to_string(),
@@ -260,6 +299,13 @@ impl Page {
} }
} }
/// Replace the site-wide description with one written for this page.
#[must_use]
pub fn with_description(mut self, text: impl Into<String>) -> Self {
self.description = text.into();
self
}
pub fn is_admin(&self) -> bool { pub fn is_admin(&self) -> bool {
self.viewer self.viewer
.as_ref() .as_ref()
@@ -454,6 +500,15 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
if path.starts_with("/dashboard") { if path.starts_with("/dashboard") {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
} }
// Fail closed. A response that names no policy of its own — `/login`,
// `/account`, a redirect, an error page, or whatever route is added next —
// is uncacheable, because a CDN with a cache-everything rule would
// otherwise apply its own default TTL (Cloudflare: two hours on a 200) to
// a page that may well be personalised. Handlers that mean to be cached
// say so explicitly, and this never overrides them (§3.12).
if !headers.contains_key(header::CACHE_CONTROL) {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
if headers if headers
.get(header::CONTENT_TYPE) .get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
@@ -462,7 +517,7 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
headers.append(header::VARY, HeaderValue::from_static("Cookie")); headers.append(header::VARY, HeaderValue::from_static("Cookie"));
headers.insert( headers.insert(
header::HeaderName::from_static("speculation-rules"), header::HeaderName::from_static("speculation-rules"),
HeaderValue::from_static("\"/static/speculation.json\""), SPECULATION_RULES.clone(),
); );
} }
response response
@@ -561,8 +616,24 @@ async fn map_forbidden(request: Request, next: Next) -> Response {
} }
} }
/// The `?v=` cache-buster on a `/static/*` URL, when the reference carries one.
#[derive(Debug, Deserialize)]
struct AssetQuery {
#[serde(default)]
v: Option<String>,
}
/// A versioned URL names one immutable build, so it may be held for a year.
const VERSIONED_CACHE: &str = "public, max-age=31536000, immutable";
/// A bare `/static/…` URL is not a promise about its content, so it gets an
/// hour and revalidates against the ETag. Someone else's link or an old
/// bookmark must not pin a stale asset for a year.
const UNVERSIONED_CACHE: &str = "public, max-age=3600";
async fn static_asset( async fn static_asset(
axum::extract::Path(file): axum::extract::Path<String>, axum::extract::Path(file): axum::extract::Path<String>,
axum::extract::Query(query): axum::extract::Query<AssetQuery>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Response { ) -> Response {
let asset: (&str, &'static [u8]) = match file.as_str() { let asset: (&str, &'static [u8]) = match file.as_str() {
@@ -587,6 +658,11 @@ async fn static_asset(
"Newsreader-italic.woff2" => ("font/woff2", NEWSREADER_ITALIC), "Newsreader-italic.woff2" => ("font/woff2", NEWSREADER_ITALIC),
_ => return WebError::NotFound.into_response(), _ => return WebError::NotFound.into_response(),
}; };
let cache_control = if query.v.is_some() {
VERSIONED_CACHE
} else {
UNVERSIONED_CACHE
};
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1))); let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
if headers if headers
.get(header::IF_NONE_MATCH) .get(header::IF_NONE_MATCH)
@@ -597,10 +673,7 @@ async fn static_asset(
StatusCode::NOT_MODIFIED, StatusCode::NOT_MODIFIED,
[ [
(header::ETAG, etag), (header::ETAG, etag),
( (header::CACHE_CONTROL, cache_control.to_string()),
header::CACHE_CONTROL,
"public, max-age=31536000, immutable".into(),
),
], ],
) )
.into_response(); .into_response();
@@ -609,10 +682,7 @@ async fn static_asset(
StatusCode::OK, StatusCode::OK,
[ [
(header::CONTENT_TYPE, asset.0.to_string()), (header::CONTENT_TYPE, asset.0.to_string()),
( (header::CACHE_CONTROL, cache_control.to_string()),
header::CACHE_CONTROL,
"public, max-age=31536000, immutable".into(),
),
(header::ETAG, etag), (header::ETAG, etag),
], ],
asset.1, asset.1,
@@ -705,7 +775,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
anonymous.headers().get("speculation-rules").unwrap(), anonymous.headers().get("speculation-rules").unwrap(),
"\"/static/speculation.json\"" &format!("\"/static/speculation.json?v={}\"", ASSET_VERSION.as_str())
); );
let response = app let response = app
@@ -1100,27 +1170,95 @@ mod tests {
let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str()); let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
assert!(html.contains(&expected), "{html}"); assert!(html.contains(&expected), "{html}");
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION))); assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
// The fonts ride inside the stylesheet; a preload would fetch them twice. // Nothing is preloaded. A preload of the 132 KB regular face shares
// bandwidth with the 12 KB render-blocking stylesheet, which is what
// first paint actually waits on: it pushed simulated FCP from ~1.0 s to
// ~1.8 s on Lighthouse mobile, for a face the metric-matched fallbacks
// already stand in for.
assert!(!html.contains("rel=\"preload\""), "{html}"); assert!(!html.contains("rel=\"preload\""), "{html}");
// The italic face is never referenced from the HTML either.
assert!(!html.contains("Newsreader-italic.woff2"), "{html}");
// `defer` keeps app.js out of Lighthouse's render-blocking list; it has
// no readyState or DOMContentLoaded dependence, so deferring is safe.
let app_js = format!(
"<script defer src=\"/static/app.js?v={}\"></script>",
ASSET_VERSION.as_str()
);
assert!(html.contains(&app_js), "{html}");
} }
#[test] #[test]
fn stylesheet_embeds_both_newsreader_faces() { fn pages_render_a_meta_description_and_escape_it() {
let render = |page: Page| {
ErrorTemplate {
page,
heading: "h".into(),
message: "m".into(),
}
.render()
.unwrap()
};
// Every page carries a description; the default one when none is set.
assert!(DEFAULT_DESCRIPTION.len() <= 160, "{DEFAULT_DESCRIPTION}");
let html = render(Page::new("t", None, "latest"));
assert!(
html.contains(
"<meta name=\"description\" content=\"A daily newspaper of the web: articles \
hand-picked from one reader&#39;s feeds, published every morning as an EPUB and \
readable here.\">"
),
"{html}"
);
// A page-specific one replaces it, HTML-escaped into the attribute.
let page = Page::new("t", None, "latest")
.with_description("Issue \"No. 3\" & <b>4</b> for O'Donnell");
assert_eq!(page.description, "Issue \"No. 3\" & <b>4</b> for O'Donnell");
let html = render(page);
assert!(
html.contains(
"<meta name=\"description\" content=\"Issue &#34;No. 3&#34; &#38; \
&#60;b&#62;4&#60;/b&#62; for O&#39;Donnell\">"
),
"{html}"
);
assert!(!html.contains("<b>4</b>"), "{html}");
assert!(!html.contains(DEFAULT_DESCRIPTION), "{html}");
}
#[test]
fn stylesheet_points_both_newsreader_faces_at_versioned_urls() {
let css = APP_CSS.as_str(); let css = APP_CSS.as_str();
assert_eq!(css.matches("url(data:font/woff2;base64,").count(), 2); let version = ASSET_VERSION.as_str();
assert!(!css.contains("/static/Newsreader")); assert!(
assert!(css.contains("font-display:block"), "{}", &css[..200]); css.contains(&format!("url(/static/Newsreader.woff2?v={version})")),
"regular face missing from stylesheet"
);
assert!(
css.contains(&format!("url(/static/Newsreader-italic.woff2?v={version})")),
"italic face missing from stylesheet"
);
// Unversioned references would be cached forever under a stale URL, and
// inlined faces would put ~280 KB back into the render-blocking sheet.
assert!(!css.contains("url(/static/Newsreader.woff2)"));
assert!(!css.contains("url(/static/Newsreader-italic.woff2)"));
assert!(!css.contains("data:font"));
// `swap` paints immediately in the metric-matched local fallback.
assert_eq!(css.matches("font-display:swap").count(), 2);
assert!(!css.contains("font-display:block"));
} }
#[tokio::test] #[tokio::test]
async fn static_assets_use_content_hash_etags() { async fn static_assets_use_content_hash_etags() {
let (_dir, state) = test_state(Config::default()).await; let (_dir, state) = test_state(Config::default()).await;
let app = router(state); let app = router(state);
let versioned = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
let first = app let first = app
.clone() .clone()
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/static/app.css") .uri(versioned.as_str())
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@@ -1136,8 +1274,8 @@ mod tests {
.clone() .clone()
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/static/app.css") .uri(versioned.as_str())
.header(header::IF_NONE_MATCH, etag) .header(header::IF_NONE_MATCH, etag.clone())
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@@ -1149,10 +1287,48 @@ mod tests {
"public, max-age=31536000, immutable" "public, max-age=31536000, immutable"
); );
// Without `?v=` the URL is not a promise about its content: an hour,
// revalidated against the same ETag, never a pinned year.
let bare = app
.clone()
.oneshot(
Request::builder()
.uri("/static/app.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(bare.status(), StatusCode::OK);
assert_eq!(
bare.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=3600"
);
assert_eq!(bare.headers().get(header::ETAG).unwrap(), &etag);
let bare_cached = app
.clone()
.oneshot(
Request::builder()
.uri("/static/app.css")
.header(header::IF_NONE_MATCH, etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(bare_cached.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
bare_cached.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=3600"
);
let rules = app let rules = app
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/static/speculation.json") .uri(format!(
"/static/speculation.json?v={}",
ASSET_VERSION.as_str()
))
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@@ -1163,6 +1339,107 @@ mod tests {
rules.headers().get(header::CONTENT_TYPE).unwrap(), rules.headers().get(header::CONTENT_TYPE).unwrap(),
"application/speculationrules+json" "application/speculationrules+json"
); );
assert_eq!(
rules.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=31536000, immutable"
);
}
/// Everything the layout and the headers point at must carry `?v=`: an
/// unversioned reference to an immutably cached asset can never be updated.
#[tokio::test]
async fn every_referenced_static_url_is_versioned() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let version = ASSET_VERSION.as_str();
assert_eq!(
response.headers().get("speculation-rules").unwrap(),
&format!("\"/static/speculation.json?v={version}\"")
);
let html = response_text(response).await;
for asset in ["favicon.svg", "app.css", "theme.js"] {
assert!(
html.contains(&format!("/static/{asset}?v={version}")),
"{asset} is referenced without ?v= in {html}"
);
assert!(
!html.contains(&format!("\"/static/{asset}\"")),
"{asset} still has a bare reference in {html}"
);
}
// The fonts are reached through the stylesheet, not the layout.
assert!(APP_CSS.contains(&format!("url(/static/Newsreader.woff2?v={version})")));
}
/// A response that names no policy of its own must not be cacheable: a CDN
/// cache-everything rule would otherwise give it the CDN's default TTL.
#[tokio::test]
async fn responses_without_a_policy_default_to_no_store() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
for uri in ["/login", "/healthz", "/no-such-page"] {
let response = app
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store",
"{uri}"
);
}
}
/// Cookie- and Basic-auth-gated routes must be uncacheable everywhere, on
/// every status code, whatever the CDN is told to do.
#[tokio::test]
async fn download_and_opds_routes_are_private_no_store() {
let mut config = Config::default();
config.publish.epub_dir = std::path::PathBuf::from("/nonexistent/epub");
config.publish.xtc_dir = std::path::PathBuf::from("/nonexistent/xtc");
let (_dir, state) = test_state(config).await;
let app = router(state);
for uri in [
"/opds",
"/opds/",
crate::publish::OPDS_PATH,
"/files/epub/missing.epub",
"/files/xtc/missing.xtch",
"/files/epub/..%2Fescape",
] {
let response = app
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"private, no-store",
"{uri} answered {}",
response.status()
);
}
// The Basic auth challenge is a response too.
let mut config = Config::default();
config.server.basic_auth_user = Some("daily".into());
config.server.basic_auth_pass = Some("hunter2".into());
let (_dir, state) = test_state(config).await;
let app = router(state);
let challenge = app
.oneshot(Request::builder().uri("/opds").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(challenge.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
challenge.headers().get(header::CACHE_CONTROL).unwrap(),
"private, no-store"
);
} }
#[tokio::test] #[tokio::test]
+58 -8
View File
@@ -11,6 +11,19 @@ use crate::web::issue::{self, Download};
use crate::web::session::{AuthSession, Viewer}; use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, WebError}; use crate::web::{Html, Page, WebError};
/// The `<meta name="description">` for the landing page before the first issue
/// of the day exists.
const NO_ISSUE_DESCRIPTION: &str = concat!(
"The latest issue of The Daily EPUB is not out yet; ",
"the next one lands tomorrow morning."
);
/// The `<meta name="description">` for the archive index.
const ARCHIVE_DESCRIPTION: &str = concat!(
"Every issue of The Daily EPUB, newest first: browse the archive by month ",
"and read or download any past morning's paper."
);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PublicIssue { pub struct PublicIssue {
pub date: Date, pub date: Date,
@@ -140,6 +153,20 @@ impl From<&Issue> for PublicIssue {
} }
} }
impl PublicIssue {
/// The `<meta name="description">` for an issue page: the same counts the
/// masthead prints, in a sentence a search result can show.
fn description(&self) -> String {
format!(
"Issue No. {} for {}: {} articles across {} sections.",
self.issue_number,
self.display_date,
self.article_count,
self.sections.len(),
)
}
}
fn domain(raw: &str) -> String { fn domain(raw: &str) -> String {
url::Url::parse(raw) url::Url::parse(raw)
.ok() .ok()
@@ -193,7 +220,8 @@ pub async fn latest(
let Some(date) = state.db.latest_issue_date().await? else { let Some(date) = state.db.latest_issue_date().await? else {
let viewer = auth.user().await.map(Viewer::from); let viewer = auth.user().await.map(Viewer::from);
let response = Html(IssuePublicTemplate { let response = Html(IssuePublicTemplate {
page: Page::new("Latest issue", viewer, "latest"), page: Page::new("Latest issue", viewer, "latest")
.with_description(NO_ISSUE_DESCRIPTION),
issue: empty_issue(), issue: empty_issue(),
downloads: Vec::new(), downloads: Vec::new(),
empty: true, empty: true,
@@ -220,9 +248,11 @@ pub async fn show_issue(
return Ok(public_cache(response, &headers)); return Ok(public_cache(response, &headers));
} }
let active_nav = if view.is_latest { "latest" } else { "archive" }; let active_nav = if view.is_latest { "latest" } else { "archive" };
let issue = PublicIssue::from(&view.issue);
let response = Html(IssuePublicTemplate { let response = Html(IssuePublicTemplate {
page: Page::new(format!("Issue {date}"), None, active_nav), page: Page::new(format!("Issue {date}"), None, active_nav)
issue: PublicIssue::from(&view.issue), .with_description(issue.description()),
issue,
downloads: Vec::new(), downloads: Vec::new(),
empty: false, empty: false,
}) })
@@ -259,7 +289,8 @@ pub async fn archive(
"Issue archive", "Issue archive",
auth.user().await.map(Viewer::from), auth.user().await.map(Viewer::from),
"archive", "archive",
), )
.with_description(ARCHIVE_DESCRIPTION),
months, months,
}) })
.into_response(); .into_response();
@@ -308,7 +339,7 @@ pub async fn feed(State(state): State<AppState>) -> Result<Response, WebError> {
StatusCode::OK, StatusCode::OK,
[ [
(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"), (header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"), (header::CACHE_CONTROL, PUBLIC_CACHE),
], ],
body, body,
) )
@@ -318,21 +349,40 @@ pub async fn feed(State(state): State<AppState>) -> Result<Response, WebError> {
pub async fn robots() -> Response { pub async fn robots() -> Response {
( (
StatusCode::OK, StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")], [
(header::CONTENT_TYPE, "text/plain; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
"User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n", "User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n",
) )
.into_response() .into_response()
} }
/// `Cache-Control` for an anonymous public page (§3.12).
///
/// Five minutes, for browsers and shared caches alike. An issue changes once a
/// day, so the only staleness this allows is the few minutes after a publish,
/// and the operator never sees even that: signed-in requests get
/// [`PRIVATE_CACHE`]. A longer shared-cache age was tried and rejected: it
/// needs a purge call after every publish to stay correct, and at this site's
/// traffic an edge rarely keeps a page long enough for the extra age to pay
/// for that machinery.
pub const PUBLIC_CACHE: &str = "public, max-age=300";
/// `Cache-Control` for anything a signed-in reader sees, and for every
/// authenticated download. `private` keeps it out of shared caches even if a
/// CDN cache rule is misconfigured; `no-store` keeps it off disk.
pub const PRIVATE_CACHE: &str = "private, no-store";
fn public_cache(mut response: Response, request_headers: &HeaderMap) -> Response { fn public_cache(mut response: Response, request_headers: &HeaderMap) -> Response {
let value = if request_headers let value = if request_headers
.get(header::COOKIE) .get(header::COOKIE)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(|cookies| cookies.contains("daily_session=")) .is_some_and(|cookies| cookies.contains("daily_session="))
{ {
"private, no-store" PRIVATE_CACHE
} else { } else {
"public, max-age=300" PUBLIC_CACHE
}; };
response.headers_mut().insert( response.headers_mut().insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
+8 -2
View File
@@ -280,10 +280,16 @@ struct AccountTemplate {
error: String, error: String,
} }
/// The `<meta name="description">` for both renders of the sign-in page.
const LOGIN_DESCRIPTION: &str = concat!(
"Sign in to The Daily EPUB to read the full issue, ",
"rate what you read, and download the morning's editions."
);
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response { pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
let viewer = auth.user().await.map(Viewer::from); let viewer = auth.user().await.map(Viewer::from);
Html(LoginTemplate { Html(LoginTemplate {
page: Page::new("Sign in", viewer, "login"), page: Page::new("Sign in", viewer, "login").with_description(LOGIN_DESCRIPTION),
next: valid_next(query.next.as_deref()).to_string(), next: valid_next(query.next.as_deref()).to_string(),
error: String::new(), error: String::new(),
}) })
@@ -316,7 +322,7 @@ pub async fn login(
None => Ok(( None => Ok((
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Html(LoginTemplate { Html(LoginTemplate {
page: Page::new("Sign in", None, "login"), page: Page::new("Sign in", None, "login").with_description(LOGIN_DESCRIPTION),
next: destination, next: destination,
error: "invalid username or password".into(), error: "invalid username or password".into(),
}), }),
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([mLine truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Newsreader Fallback Georgia", "Newsreader Fallback Times", "Newsreader Fallback Noto Serif", "Newsreader Fallback DejaVu Serif", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-vaLine truncated
+62 -5
View File
@@ -9,7 +9,7 @@
font-style: normal; font-style: normal;
font-weight: 200 800; font-weight: 200 800;
font-stretch: normal; font-stretch: normal;
font-display: block; font-display: swap;
src: url("/static/Newsreader.woff2") format("woff2"); src: url("/static/Newsreader.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@@ -19,15 +19,70 @@
font-style: italic; font-style: italic;
font-weight: 200 800; font-weight: 200 800;
font-stretch: normal; font-stretch: normal;
font-display: block; font-display: swap;
src: url("/static/Newsreader-italic.woff2") format("woff2"); src: url("/static/Newsreader-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* Metric-matched local fallbacks for Newsreader.
`font-display: swap` paints text immediately in whichever of these the
platform has; the overrides make that first paint occupy the same box as
Newsreader, so the swap changes glyph shapes and nothing else (CLS 0).
size-adjust = (Newsreader xWidthAvg/upm) / (fallback xWidthAvg/upm)
ascent/descent-override = (Newsreader ascent|descent / upm) / size-adjust
Newsreader (src/web/static/fonts/Newsreader.woff2, wght 400 at opsz 17, the
body size): upm 2000, ascent 1470, descent -530, lineGap 0, and a
frequency-weighted average character width of 837 (0.4185 em). Fallback
widths are @capsizecss/metrics v4.2.0 `xWidthAvg`, which uses the same
weighting, except DejaVu Serif which is measured the same way from
/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf. */
@font-face {
font-family: "Newsreader Fallback Georgia";
src: local("Georgia");
/* xWidthAvg 913 / upm 2048 */
size-adjust: 93.88%;
ascent-override: 78.29%;
descent-override: 28.23%;
line-gap-override: 0%;
}
@font-face {
font-family: "Newsreader Fallback Times";
src: local("Times New Roman"), local("Liberation Serif"), local("Tinos");
/* xWidthAvg 832 / upm 2048 */
size-adjust: 103.02%;
ascent-override: 71.35%;
descent-override: 25.72%;
line-gap-override: 0%;
}
@font-face {
font-family: "Newsreader Fallback Noto Serif";
src: local("Noto Serif");
/* xWidthAvg 481 / upm 1000 */
size-adjust: 87.01%;
ascent-override: 84.48%;
descent-override: 30.46%;
line-gap-override: 0%;
}
@font-face {
font-family: "Newsreader Fallback DejaVu Serif";
src: local("DejaVu Serif");
/* xWidthAvg 1058 / upm 2048 */
size-adjust: 81.01%;
ascent-override: 90.73%;
descent-override: 32.71%;
line-gap-override: 0%;
}
:root { :root {
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568; --paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#6b665a;
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16; --rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light; --loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#7d631c; color-scheme:light;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -50,7 +105,9 @@
--color-rule-strong:var(--rule-strong); --color-accent:var(--accent); --color-rule-strong:var(--rule-strong); --color-accent:var(--accent);
--color-accent-hover:var(--accent-hover); --color-loved:var(--loved); --color-good:var(--good); --color-accent-hover:var(--accent-hover); --color-loved:var(--loved); --color-good:var(--good);
--color-down:var(--down); --color-warn:var(--warn); --color-down:var(--down); --color-warn:var(--warn);
--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif; --font-serif:"Newsreader", "Newsreader Fallback Georgia", "Newsreader Fallback Times",
"Newsreader Fallback Noto Serif", "Newsreader Fallback DejaVu Serif",
"Iowan Old Style", "Palatino Linotype", Georgia, serif;
--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;
} }
+8 -2
View File
@@ -3,8 +3,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ page.description }}">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>{{ page.title }} · The Daily EPUB</title> <title>{{ page.title }} · The Daily EPUB</title>
{# No font preload here on purpose. Preloading the 132 KB regular face made
it share bandwidth with this 12 KB render-blocking sheet, so the sheet —
and therefore first paint — landed later: simulated FCP went from ~1.0 s
to ~1.8 s on Lighthouse mobile. The metric-matched fallbacks in
tailwind.css make waiting for the real face cost nothing visible. #}
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}"> <link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
{# Keep this synchronous script *after* the stylesheet: Gecko will not run a {# Keep this synchronous script *after* the stylesheet: Gecko will not run a
parser-blocking script while a stylesheet is pending, so the parser stalls parser-blocking script while a stylesheet is pending, so the parser stalls
@@ -12,7 +18,7 @@
flush (Bugzilla 1459305). Chrome blocks rendering on the sheet regardless. #} flush (Bugzilla 1459305). Chrome blocks rendering on the sheet regardless. #}
<script src="/static/theme.js?v={{ page.asset_version }}"></script> <script src="/static/theme.js?v={{ page.asset_version }}"></script>
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml"> <link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
<link rel="icon" href="/static/favicon.svg"> <link rel="icon" href="/static/favicon.svg?v={{ page.asset_version }}">
</head> </head>
<body> <body>
<a class="fixed left-3 top-3 z-50 -translate-y-20 bg-ink px-3 py-2 font-sans text-sm text-paper no-underline focus:translate-y-0" href="#content">Skip to content</a> <a class="fixed left-3 top-3 z-50 -translate-y-20 bg-ink px-3 py-2 font-sans text-sm text-paper no-underline focus:translate-y-0" href="#content">Skip to content</a>
@@ -51,6 +57,6 @@
{% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %} {% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %}
<main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main> <main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main>
<footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer> <footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer>
<script src="/static/app.js?v={{ page.asset_version }}"></script> <script defer src="/static/app.js?v={{ page.asset_version }}"></script>
</body> </body>
</html> </html>