Compare commits
9
Commits
239125a8e5
...
f634f06bf1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f634f06bf1 | ||
|
|
de332cd4b3 | ||
|
|
e616638c36 | ||
|
|
7d12f53314 | ||
|
|
41fe61a691 | ||
|
|
2388d7bd02 | ||
|
|
62803d73a6 | ||
|
|
4c8eeda147 | ||
|
|
04bf14f710 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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'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 "No. 3" & \
|
||||||
|
<b>4</b> for O'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
@@ -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
@@ -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,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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user