Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48338a0c3d | ||
|
|
9e05d16a46 | ||
|
|
24eee8bc61 |
@@ -3,8 +3,6 @@ design/.DS_Store
|
||||
|
||||
# Secrets and local environment
|
||||
.env*
|
||||
!deploy/.env.example
|
||||
deploy/.env
|
||||
deploy/secrets/
|
||||
|
||||
# Build artifacts and dependencies
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# MealQuest production environment. Copy to deploy/.env (never committed)
|
||||
# and replace the placeholders. The service fails fast: a missing or invalid
|
||||
# required variable stops it at boot with the variable named.
|
||||
#
|
||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||
# one value per file, owned by the container user and readable only by it:
|
||||
#
|
||||
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
|
||||
#
|
||||
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
|
||||
# *_FILE variables at them.
|
||||
|
||||
# --- Compose -----------------------------------------------------------------
|
||||
|
||||
# Host address the service is published on, as <address>:8090. Production
|
||||
# binds the LAN address 10.60.1.14 only; 127.0.0.1 suits a dev machine.
|
||||
MEALQUEST_BIND_ADDRESS=127.0.0.1
|
||||
|
||||
# --- Service -----------------------------------------------------------------
|
||||
|
||||
# Public origin the household opens. Fixes the OIDC redirect URI
|
||||
# (<url>/auth/callback) and the Secure cookies. Must be https:// (plain
|
||||
# http:// is accepted only for localhost, 127.0.0.1 and [::1]).
|
||||
MEALQUEST_PUBLIC_URL=https://mealquest.cubecraftlabs.com
|
||||
|
||||
# How long a graceful shutdown may take before in-flight requests are cut.
|
||||
MEALQUEST_SHUTDOWN_TIMEOUT=10s
|
||||
|
||||
# Optional. Comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured. Production lists Traefik;
|
||||
# unset trusts no peer.
|
||||
MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
|
||||
# IANA zone the household settings are created with on first boot. Required
|
||||
# until those settings exist; ignored afterwards (change the zone in
|
||||
# household settings instead). Confirm the household's zone before the first
|
||||
# deploy.
|
||||
MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
|
||||
# --- Authentik OIDC ----------------------------------------------------------
|
||||
|
||||
# Issuer of the Authentik application "mealquest". Discovered at boot, so it
|
||||
# must be reachable from the container. Must be https://.
|
||||
MEALQUEST_OIDC_ISSUER=https://auth.cubecraftlabs.com/application/o/mealquest/
|
||||
MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
# Comma-separated Authentik groups whose members may sign in.
|
||||
MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||
MEALQUEST_SESSION_IDLE=720h
|
||||
MEALQUEST_SESSION_MAX=4320h
|
||||
|
||||
# --- Set by deploy/compose.yaml; do not set here -----------------------------
|
||||
#
|
||||
# MEALQUEST_HTTP_ADDR=:8080
|
||||
# MEALQUEST_DATA_DIR=/data
|
||||
# MEALQUEST_SECRET_KEY_FILE=/run/secrets/secret_key
|
||||
# Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM).
|
||||
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# The Authentik provider's client secret.
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
# Added to deploy/compose.yaml and this file by the features that use them.
|
||||
#
|
||||
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
#
|
||||
# Google Calendar OAuth web client (Feature 16).
|
||||
# MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||
#
|
||||
# SearXNG recipe search sidecar on the compose network (Feature 07).
|
||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||
@@ -1,886 +0,0 @@
|
||||
# MealQuest deployment runbook
|
||||
|
||||
How MealQuest is deployed on office-srvr (10.60.1.14), how it is reached
|
||||
through Authentik, Traefik and DNS, how it is updated and rolled back, how
|
||||
its data is backed up and restored, and how its secrets are rotated. Values
|
||||
are the ones in use on 2026-10-05. Secrets themselves are never written
|
||||
here.
|
||||
|
||||
Paths used below:
|
||||
|
||||
| Name | Path |
|
||||
| --- | --- |
|
||||
| Checkout on the host | `/home/overseer/mealquest` |
|
||||
| Data volume (`mealquest-data`) on the host | `/var/lib/docker/volumes/mealquest-data/_data` |
|
||||
| Same volume inside the containers | `/data` |
|
||||
| Database | `mealquest.db` (+ `mealquest.db-wal`, `mealquest.db-shm` while open) |
|
||||
| Media | `media/` in the data volume |
|
||||
| Backup target (NAS, over SSH) | `joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups` |
|
||||
| Same target over SFTP (Synology chroot) | `/homes/Joshua/mealquest-backups` |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
On office-srvr (10.60.1.14), as recorded on 2026-10-05:
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| OS | Ubuntu 26.04.1 LTS |
|
||||
| Docker Engine / Compose plugin | 29.8.2 / 5.6.0 |
|
||||
| git, rsync, iptables | 2.53.0, 3.4.1, 1.8.11 (nf_tables) |
|
||||
| Account | `overseer`, with sudo |
|
||||
| Network | LAN reachability to Traefik (10.60.1.33), Authentik (`auth.cubecraftlabs.com`) and the NAS (10.60.1.50, SSH) |
|
||||
|
||||
Elsewhere:
|
||||
|
||||
- Admin access to Authentik at <https://auth.cubecraftlabs.com>.
|
||||
- SSH access to the Traefik host 10.60.1.33.
|
||||
- Cloudflare access for the `cubecraftlabs.com` zone, and admin access to
|
||||
both Pi-holes (10.60.1.101 `dns.ccl`, 10.60.1.85 `dns2.ccl`).
|
||||
- A NAS account `joshua` on 10.60.1.50 with the backup directory
|
||||
`/volume1/homes/Joshua/mealquest-backups` (see "Backups").
|
||||
- A Gitea token able to read `Maniacs_Mansion/MealQuest`, for the clone.
|
||||
|
||||
## Stack and first deploy
|
||||
|
||||
The compose stack is `deploy/compose.yaml`: the `mealquest` service (built
|
||||
from the repository `Dockerfile`, runs as uid/gid 10001, listens on 8080 in
|
||||
the container) and the `litestream` service. Data lives in the named volume
|
||||
`mealquest-data`.
|
||||
|
||||
### 1. Choose and check the port
|
||||
|
||||
The container's 8080 is published on `10.60.1.14:8090`. Ports 8080 and 8081
|
||||
on this host belong to NestQuest, so 8090 was chosen. Before the first
|
||||
deploy it was checked free with:
|
||||
|
||||
```sh
|
||||
ss -ltnp | grep 8090 # printed nothing
|
||||
```
|
||||
|
||||
After the deploy the same host shows NestQuest on 8080/8081 and MealQuest
|
||||
on 8090:
|
||||
|
||||
```
|
||||
LISTEN 0 4096 10.60.1.14:8080 0.0.0.0:*
|
||||
LISTEN 0 4096 10.60.1.14:8081 0.0.0.0:*
|
||||
LISTEN 0 4096 10.60.1.14:8090 0.0.0.0:*
|
||||
```
|
||||
|
||||
The published host port is fixed in `deploy/compose.yaml`, in
|
||||
`deploy/systemd/mealquest-firewall.sh` and in the Traefik route; change all
|
||||
three together.
|
||||
|
||||
### 2. Clone
|
||||
|
||||
The checkout lives at `/home/overseer/mealquest`, next to NestQuest's
|
||||
`~/nestquest` (not `/opt/mealquest`). Clone over HTTPS with a Gitea token,
|
||||
then reset the remote to the clean URL so the token is not stored in
|
||||
`.git/config`:
|
||||
|
||||
```sh
|
||||
cd /home/overseer
|
||||
git clone https://<user>:<token>@code.cubecraftlabs.com/Maniacs_Mansion/MealQuest.git mealquest
|
||||
cd mealquest
|
||||
git remote set-url origin https://code.cubecraftlabs.com/Maniacs_Mansion/MealQuest.git
|
||||
git remote -v # must show no token
|
||||
git checkout main
|
||||
```
|
||||
|
||||
### 3. Environment file
|
||||
|
||||
`deploy/.env` (git-ignored) is copied from `deploy/.env.example` and is
|
||||
mode 0600, owned by `overseer`:
|
||||
|
||||
```sh
|
||||
cp deploy/.env.example deploy/.env
|
||||
chmod 600 deploy/.env
|
||||
```
|
||||
|
||||
Then edit `deploy/.env` to the values below. In particular
|
||||
**`MEALQUEST_BIND_ADDRESS` must be changed**: `.env.example` ships
|
||||
`127.0.0.1` (a dev machine), and left as is the service listens only on
|
||||
the host's loopback, which Traefik cannot reach. Set it to the LAN address
|
||||
and check:
|
||||
|
||||
```sh
|
||||
sed -i 's/^MEALQUEST_BIND_ADDRESS=.*/MEALQUEST_BIND_ADDRESS=10.60.1.14/' deploy/.env
|
||||
grep '^MEALQUEST_BIND_ADDRESS=' deploy/.env # MEALQUEST_BIND_ADDRESS=10.60.1.14
|
||||
docker compose -f deploy/compose.yaml config | grep -A4 'ports:'
|
||||
# host_ip: 10.60.1.14, target: 8080, published: "8090"
|
||||
```
|
||||
|
||||
After step 5 the listener is `10.60.1.14:8090` (container port 8080):
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yaml port mealquest 8080 # 10.60.1.14:8090
|
||||
sudo ss -ltnp 'sport = :8090' # 10.60.1.14:8090 only, no 0.0.0.0 or 127.0.0.1
|
||||
```
|
||||
|
||||
Values in use:
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `MEALQUEST_BIND_ADDRESS` | `10.60.1.14` |
|
||||
| `MEALQUEST_PUBLIC_URL` | `https://mealquest.cubecraftlabs.com` |
|
||||
| `MEALQUEST_SHUTDOWN_TIMEOUT` | `10s` |
|
||||
| `MEALQUEST_TRUSTED_PROXY` | `10.60.1.33` |
|
||||
| `MEALQUEST_DEFAULT_TZ` | `America/New_York` |
|
||||
| `MEALQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/mealquest/` |
|
||||
| `MEALQUEST_OIDC_CLIENT_ID` | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret) |
|
||||
| `MEALQUEST_OIDC_ALLOWED_GROUPS` | `mealquest-admin` |
|
||||
| `MEALQUEST_SESSION_IDLE` / `MEALQUEST_SESSION_MAX` | `720h` / `4320h` |
|
||||
|
||||
`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR` and the `*_FILE` variables are
|
||||
set by `deploy/compose.yaml` and must not appear in `deploy/.env`.
|
||||
|
||||
### 4. Secrets
|
||||
|
||||
`deploy/secrets/` (git-ignored) is mode 0700, owned by `overseer`. Each
|
||||
file holds one value, is owned by `10001:10001` (host user `mealquest`) and
|
||||
is mode 0600. Compose bind-mounts file secrets with their host owner and
|
||||
mode, so any other owner or mode stops the container reading them and the
|
||||
service refuses to start.
|
||||
|
||||
| File | Content | Mounted at |
|
||||
| --- | --- | --- |
|
||||
| `secret_key` | base64 of 32 random bytes; encrypts stored secrets (AES-256-GCM) | `/run/secrets/secret_key` |
|
||||
| `oidc_client_secret` | Client Secret of the Authentik provider `mealquest` | `/run/secrets/oidc_client_secret` |
|
||||
| `litestream_key` | dedicated SSH private key for the NAS (see "Backups") | `/etc/litestream/sftp_key` |
|
||||
|
||||
```sh
|
||||
mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
# litestream_key: see "Backups" > "Database — Litestream"
|
||||
sudo chown 10001:10001 deploy/secrets/*
|
||||
sudo chmod 600 deploy/secrets/*
|
||||
sudo ls -la deploy/secrets # three files, mealquest mealquest, -rw-------
|
||||
```
|
||||
|
||||
The `mealquest` host user (uid/gid 10001) is created in "Media — nightly
|
||||
rsync"; until it exists `ls` shows the numeric ids.
|
||||
|
||||
### 5. Firewall, then the stack
|
||||
|
||||
Install the firewall unit and the stack unit first (see "Host firewall"
|
||||
and "Stack unit"), so port 8090 is never open to the LAN. Create the media
|
||||
directory (see "Media — nightly rsync"), then build and start:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yaml build
|
||||
sudo systemctl enable --now mealquest # starts mealquest-firewall first
|
||||
docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up
|
||||
curl -fsS http://10.60.1.14:8090/health
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200
|
||||
```
|
||||
|
||||
### Host checkout state on 2026-10-05
|
||||
|
||||
The first deploy ran before Feature 07 reached `main`. The checkout is at
|
||||
`main` `9cddbbb` and the Feature 07 files under `deploy/` were copied in
|
||||
from the feature branch: `deploy/compose.yaml` and `deploy/.env.example`
|
||||
are listed in `.git/info/exclude`, the rest show as untracked. Before the
|
||||
first routine deploy of a `main` that contains `deploy/`, remove those
|
||||
`.git/info/exclude` lines and the copied files (keep `deploy/.env` and
|
||||
`deploy/secrets/`), or `git checkout` refuses to overwrite them:
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
git status --porcelain --untracked-files=all deploy # the copied files
|
||||
sed -i '/^deploy\/\(compose.yaml\|.env.example\)$/d' .git/info/exclude
|
||||
git clean -n deploy # dry run: must not list .env or secrets/ (both git-ignored)
|
||||
git clean -f deploy
|
||||
```
|
||||
|
||||
Then follow "Routine deploy"; the checkout brings the tracked copies back.
|
||||
|
||||
## Authentik
|
||||
|
||||
Sign-in is OpenID Connect against Authentik at
|
||||
<https://auth.cubecraftlabs.com>.
|
||||
|
||||
| Object | Setting |
|
||||
| --- | --- |
|
||||
| Provider | OAuth2/OpenID provider `mealquest`, client type **Confidential** |
|
||||
| Redirect URI | `https://mealquest.cubecraftlabs.com/auth/callback` (strict) |
|
||||
| Signing | RS256, with the instance's signing key |
|
||||
| Scopes | MealQuest requests `openid profile email groups offline_access`; the provider's scope mappings include openid, profile, email and offline_access, and the `groups` claim is delivered by a property mapping (the default profile mapping carries `groups`) |
|
||||
| Application | name MealQuest, slug `mealquest`, provider `mealquest` |
|
||||
| Group | `mealquest-admin`, bound to the application; household members are added to it |
|
||||
| Client ID | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret; in `deploy/.env` as `MEALQUEST_OIDC_CLIENT_ID`) |
|
||||
| Client Secret | in `deploy/secrets/oidc_client_secret` only |
|
||||
|
||||
MealQuest refuses a sign-in unless the user's `groups` claim (from the ID
|
||||
token, or from userinfo when the token has none) contains a group in
|
||||
`MEALQUEST_OIDC_ALLOWED_GROUPS`.
|
||||
|
||||
Check discovery from the Docker host — the service discovers the issuer
|
||||
at boot and will not start if it cannot:
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||
https://auth.cubecraftlabs.com/application/o/mealquest/.well-known/openid-configuration # 200
|
||||
```
|
||||
|
||||
On 2026-10-05 discovery returned 200 with issuer
|
||||
`https://auth.cubecraftlabs.com/application/o/mealquest/` and
|
||||
`id_token_signing_alg_values_supported: ["RS256"]`.
|
||||
|
||||
## Reverse proxy (Traefik)
|
||||
|
||||
MealQuest is published at <https://mealquest.cubecraftlabs.com> by the
|
||||
shared Traefik instance on 10.60.1.33 (Feature 07, task 0c1dc172). The
|
||||
route is a dynamic-configuration file that lives on the Traefik host only,
|
||||
at `/home/overseer/Code/traefik/config/dynamic/mealquest-ccl.yml`. Its exact
|
||||
contents:
|
||||
|
||||
```yaml
|
||||
# MealQuest - https://mealquest.cubecraftlabs.com
|
||||
# Feature 07 task 0c1dc172. Single Go binary (API + embedded PWA) on
|
||||
# office-srvr 10.60.1.14:8090. HTTP -> HTTPS is handled globally by
|
||||
# http-conf.yml, so only a websecure router is defined here.
|
||||
# This file lives on the Traefik host only; the repo copy is recorded in
|
||||
# deploy/RUNBOOK.md by task 876134bc.
|
||||
http:
|
||||
routers:
|
||||
mealquest:
|
||||
entryPoints:
|
||||
- websecure
|
||||
rule: "Host(`mealquest.cubecraftlabs.com`)"
|
||||
priority: 100
|
||||
middlewares:
|
||||
- mealquest-headers
|
||||
service: mealquest
|
||||
tls:
|
||||
certResolver: myresolver
|
||||
domains:
|
||||
- main: mealquest.cubecraftlabs.com
|
||||
|
||||
services:
|
||||
mealquest:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://10.60.1.14:8090"
|
||||
|
||||
middlewares:
|
||||
mealquest-headers:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
contentTypeNosniff: true
|
||||
frameDeny: true
|
||||
```
|
||||
|
||||
Traefik watches the dynamic directory, so saving the file loads the route;
|
||||
the certificate comes from `certResolver: myresolver` (Let's Encrypt,
|
||||
DNS-01 through Cloudflare). Check from the LAN:
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200
|
||||
```
|
||||
|
||||
## DNS
|
||||
|
||||
| Where | Record |
|
||||
| --- | --- |
|
||||
| Cloudflare, zone `cubecraftlabs.com` | `CNAME mealquest.cubecraftlabs.com` → `cubecraftlabs.com`, **proxied = true** (orange cloud). Because it is proxied, public resolvers answer with Cloudflare's anycast addresses, A and AAAA (AAAA on 2026-10-05: `2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75`), never the origin |
|
||||
| Pi-hole primary 10.60.1.101 (`dns.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) |
|
||||
| Pi-hole secondary 10.60.1.85 (`dns2.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) |
|
||||
|
||||
The local record must be on **both** Pi-holes, or clients using the
|
||||
secondary go out through Cloudflare. Check:
|
||||
|
||||
```sh
|
||||
for s in 10.60.1.101 10.60.1.85; do dig +short @$s mealquest.cubecraftlabs.com A; done
|
||||
# 10.60.1.33 from each
|
||||
```
|
||||
|
||||
**AAAA caveat.** The Pi-hole local record is A only, so both Pi-holes still
|
||||
forward AAAA queries and return Cloudflare's IPv6 addresses
|
||||
(`2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75` on 2026-10-05). An
|
||||
IPv6-capable LAN client may prefer those and reach MealQuest through
|
||||
Cloudflare instead of directly through Traefik. It still works (Cloudflare
|
||||
proxies to the public route), but it is not the LAN path. If that matters,
|
||||
add a local AAAA override or block AAAA for this name on both Pi-holes.
|
||||
|
||||
## Host firewall
|
||||
|
||||
Docker publishes `10.60.1.14:8090` past the host's INPUT rules, so the
|
||||
port is restricted in the `DOCKER-USER` chain instead: only Traefik
|
||||
(10.60.1.33) and the host itself (10.60.1.14) may connect; every other
|
||||
source is dropped.
|
||||
|
||||
Docker DNATs the published port to the container address and port 8080
|
||||
**before** `DOCKER-USER`, so a plain `-p tcp --dport 8090` rule never
|
||||
matches. The rules match the original destination `10.60.1.14:8090` with
|
||||
conntrack (`--ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL`)
|
||||
and are tagged with the comment `mealquest-firewall`. The port is never
|
||||
open while the rules change: if the chain does not already start with
|
||||
exactly the three rules, the script first inserts the DROP at position 1,
|
||||
then deletes its other tagged rules and inserts the two RETURN rules above
|
||||
the DROP. A re-run on a correct chain changes nothing. NestQuest's
|
||||
8080/8081 rules below them are left untouched.
|
||||
|
||||
| Repository file | Installed as |
|
||||
| --- | --- |
|
||||
| `deploy/systemd/mealquest-firewall.sh` | `/usr/local/sbin/mealquest-firewall` (root, 0755) |
|
||||
| `deploy/systemd/mealquest-firewall.service` | `/etc/systemd/system/mealquest-firewall.service` (root, 0644) |
|
||||
|
||||
The unit is `Type=oneshot`, `RemainAfterExit=yes`, `Requires=` and
|
||||
`PartOf=docker.service`, and `WantedBy=multi-user.target docker.service`:
|
||||
it re-applies the rules whenever Docker starts or restarts (Docker
|
||||
recreates `DOCKER-USER` empty), and a failure fails this unit only.
|
||||
|
||||
Install (done on 10.60.1.14 on 2026-10-05):
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
sudo install -o root -g root -m 0755 deploy/systemd/mealquest-firewall.sh /usr/local/sbin/mealquest-firewall
|
||||
sudo install -o root -g root -m 0644 deploy/systemd/mealquest-firewall.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mealquest-firewall
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```sh
|
||||
systemctl is-enabled mealquest-firewall; systemctl is-active mealquest-firewall # enabled, active
|
||||
sudo iptables -S DOCKER-USER
|
||||
```
|
||||
|
||||
Expected head of the chain (recorded 2026-10-05; NestQuest's rules follow):
|
||||
|
||||
```
|
||||
-A DOCKER-USER -s 10.60.1.33/32 -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j RETURN
|
||||
-A DOCKER-USER -s 10.60.1.14/32 -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j RETURN
|
||||
-A DOCKER-USER -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j DROP
|
||||
```
|
||||
|
||||
From any other LAN machine, `curl -m 5 http://10.60.1.14:8090/health` must
|
||||
time out; from 10.60.1.33 and 10.60.1.14 it returns 200.
|
||||
|
||||
## Stack unit
|
||||
|
||||
`deploy/systemd/mealquest.service` owns the compose stack and is the only
|
||||
way it starts. Both compose services use `restart: "no"`, so Docker never
|
||||
starts them on its own; the unit has `Requires=` and
|
||||
`After=mealquest-firewall.service`, so the stack starts only after the
|
||||
firewall rules are applied, and not at all if that unit fails. It is
|
||||
`PartOf=docker.service`, so restarting Docker (which recreates
|
||||
`DOCKER-USER` empty) stops the stack, re-applies the firewall, then starts
|
||||
the stack again. `ExecStart` is `docker compose … up -d` and `ExecStop` is
|
||||
`docker compose … down`; neither builds, so build before starting.
|
||||
|
||||
Containers no longer restart after a crash; `systemctl restart mealquest`
|
||||
brings them back.
|
||||
|
||||
| Repository file | Installed as |
|
||||
| --- | --- |
|
||||
| `deploy/systemd/mealquest.service` | `/etc/systemd/system/mealquest.service` (root, 0644) |
|
||||
|
||||
Install (done on 10.60.1.14 on 2026-10-05):
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
sudo install -o root -g root -m 0644 deploy/systemd/mealquest.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mealquest
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```sh
|
||||
systemctl is-enabled mealquest; systemctl is-active mealquest # enabled, active
|
||||
systemctl show mealquest -p Requires -p After # both list mealquest-firewall.service
|
||||
```
|
||||
|
||||
Start, stop and restart the whole stack with `sudo systemctl start|stop|restart
|
||||
mealquest`, never with `docker compose up` or `down`. Per-service compose
|
||||
commands (`stop mealquest`, `up -d --force-recreate litestream`) are fine
|
||||
while the unit is active, because the firewall is already in place.
|
||||
|
||||
## Routine deploy and rollback
|
||||
|
||||
Production runs `main` (a release); only `overseer` merges `dev` → `main`.
|
||||
Every deploy builds the image on the host from the checked-out commit.
|
||||
|
||||
### Deploy
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
git fetch origin
|
||||
git rev-parse --short HEAD # the SHA being replaced: note it for rollback
|
||||
git checkout --detach origin/main # or a release tag
|
||||
docker compose -f deploy/compose.yaml build
|
||||
sudo systemctl restart mealquest # applies the firewall, then down + up -d
|
||||
docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up
|
||||
curl -fsS http://10.60.1.14:8090/health
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200
|
||||
git rev-parse HEAD # the deployed SHA
|
||||
```
|
||||
|
||||
Record the deployed SHA, the previous SHA and the date in the Maestro task
|
||||
for the release. Migrations run at start-up; the service refuses to start
|
||||
if the database has an applied migration it does not know.
|
||||
|
||||
If `deploy/.env.example` gained a variable, add it to `deploy/.env` before
|
||||
the restart; the service fails fast at boot naming any missing required variable.
|
||||
|
||||
### Rollback to a previous SHA
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
git checkout --detach <previous SHA>
|
||||
docker compose -f deploy/compose.yaml build
|
||||
sudo systemctl restart mealquest
|
||||
docker compose -f deploy/compose.yaml ps
|
||||
curl -fsS http://10.60.1.14:8090/health
|
||||
```
|
||||
|
||||
A rollback across a release that added a migration fails at start-up
|
||||
(the older binary refuses the newer schema). In that case restore the
|
||||
database to a point before the upgrade — "Restore the database" with
|
||||
`-timestamp` set just before the deploy — then start the older SHA.
|
||||
Changes made since that point are lost; say so to the household.
|
||||
|
||||
## Backups
|
||||
|
||||
### Database — Litestream
|
||||
|
||||
The `litestream` compose service streams every committed change to
|
||||
`mealquest.db` to the NAS over SFTP (`deploy/litestream.yml`). It takes a
|
||||
full snapshot daily (compaction level 9) and keeps a week of snapshots
|
||||
plus the incremental LTX files between them.
|
||||
|
||||
One-time setup (already done on 10.60.1.14):
|
||||
|
||||
1. Generate the dedicated keypair, git-ignored, owned by the container
|
||||
user:
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
ssh-keygen -t ed25519 -N '' -C mealquest-litestream -f deploy/secrets/litestream_key
|
||||
# append deploy/secrets/litestream_key.pub to joshua@10.60.1.50:~/.ssh/authorized_keys
|
||||
rm deploy/secrets/litestream_key.pub
|
||||
sudo chown 10001:10001 deploy/secrets/litestream_key
|
||||
sudo chmod 0600 deploy/secrets/litestream_key
|
||||
```
|
||||
|
||||
2. The NAS host key is pinned in `deploy/litestream.yml` (`host-key`).
|
||||
|
||||
Check replication — list every LTX file at every level. Level 9 holds the
|
||||
full snapshots; the default listing (`-level 0`) shows only L0 files:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yaml exec -T litestream \
|
||||
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
|
||||
```
|
||||
|
||||
### Media — nightly rsync
|
||||
|
||||
`mealquest-media-backup.timer` runs `mealquest-media-backup.service` at
|
||||
03:30 every night. The service runs `/usr/local/sbin/mealquest-media-backup`
|
||||
(`deploy/systemd/mealquest-media-backup.sh`) as the unprivileged host user
|
||||
`mealquest` (uid/gid 10001, the app's own user) with no capabilities.
|
||||
systemd bind-mounts the data volume read-only at `/run/mealquest-data` and
|
||||
passes the Litestream key in as the credential `nas_key`; uid 10001 cannot
|
||||
otherwise reach `/var/lib/docker` or `deploy/secrets/`. The script refuses a
|
||||
`media` path that is a symlink and fails (exit 1) if `media` is missing.
|
||||
|
||||
One-time setup (already done on 10.60.1.14):
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
# Host user for the backup; ssh needs a passwd entry for its uid.
|
||||
sudo groupadd --system --gid 10001 mealquest
|
||||
sudo useradd --system --uid 10001 --gid 10001 --no-create-home \
|
||||
--home-dir /nonexistent --shell /usr/sbin/nologin mealquest
|
||||
# Pinned NAS host keys, root-owned and world-readable.
|
||||
sudo install -d -o root -g root -m 0755 /etc/mealquest
|
||||
sudo install -o root -g root -m 0644 deploy/known_hosts /etc/mealquest/known_hosts
|
||||
# The media directory; the backup fails if it is ever missing.
|
||||
sudo install -d -o 10001 -g 10001 -m 0755 /var/lib/docker/volumes/mealquest-data/_data/media
|
||||
# Script and units.
|
||||
sudo install -o root -g root -m 0755 deploy/systemd/mealquest-media-backup.sh /usr/local/sbin/mealquest-media-backup
|
||||
sudo install -o root -g root -m 0644 deploy/systemd/mealquest-media-backup.service \
|
||||
deploy/systemd/mealquest-media-backup.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mealquest-media-backup.timer
|
||||
```
|
||||
|
||||
Run it by hand and read the result:
|
||||
|
||||
```sh
|
||||
sudo systemctl start mealquest-media-backup.service
|
||||
journalctl -u mealquest-media-backup --no-pager -n 30
|
||||
```
|
||||
|
||||
To restore media, follow "Restore photos (media)" — never copy back while
|
||||
the timer or the service can still run.
|
||||
|
||||
## Restore the database
|
||||
|
||||
SQLite in WAL mode keeps recent commits in `mealquest.db-wal`, indexed by
|
||||
`mealquest.db-shm`. **A restored database must never sit beside an old WAL
|
||||
or shared-memory file**: on open, SQLite would replay the old WAL's pages
|
||||
onto the restored database and corrupt it or mix in state from the
|
||||
database it replaced. So all three files are moved aside together, with
|
||||
Litestream's local state directory `.mealquest.db-litestream`, before the
|
||||
restore — and the restore runs into an empty volume.
|
||||
|
||||
Run from `/home/overseer/mealquest`:
|
||||
|
||||
```sh
|
||||
D=/var/lib/docker/volumes/mealquest-data/_data
|
||||
A=/var/tmp/mealquest-restore-aside-$(date +%Y%m%d%H%M)
|
||||
|
||||
# 1. Stop the whole stack (app and Litestream). Nothing may hold the
|
||||
# database open; a clean close checkpoints the WAL into mealquest.db.
|
||||
sudo systemctl stop mealquest
|
||||
|
||||
# 2. Move the database, its -wal and -shm files and Litestream's state
|
||||
# aside — all of them, every time. Keep the directory until the
|
||||
# restored stack is verified.
|
||||
sudo mkdir "$A"
|
||||
for f in mealquest.db mealquest.db-wal mealquest.db-shm .mealquest.db-litestream; do
|
||||
if sudo test -e "$D/$f"; then sudo mv "$D/$f" "$A/"; fi
|
||||
done
|
||||
sudo ls -la "$D" # must list only media/
|
||||
|
||||
# 3. Restore from the NAS. --no-deps keeps the app stopped so it cannot
|
||||
# create a fresh, empty database first.
|
||||
docker compose -f deploy/compose.yaml run --rm --no-deps litestream \
|
||||
restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db
|
||||
sudo ls -la "$D" # mealquest.db only: no -wal, no -shm
|
||||
|
||||
# 4. Check the restored file before starting the app (see "Count check").
|
||||
|
||||
# 5. Start the stack and check health.
|
||||
sudo systemctl start mealquest
|
||||
docker compose -f deploy/compose.yaml ps
|
||||
curl -s -o /dev/null -w "health %{http_code}\n" http://10.60.1.14:8090/health
|
||||
```
|
||||
|
||||
To restore to a point in time, add `-timestamp 2026-10-05T23:00:00Z` (or
|
||||
`-txid`) to the `restore` command.
|
||||
|
||||
### Count check
|
||||
|
||||
`/tmp/mqcount.py` prints row counts for key tables, the number of
|
||||
application tables, a SHA-256 over every application row (first 16 hex
|
||||
digits; Litestream's own `_litestream*` tables are excluded) and
|
||||
`PRAGMA integrity_check`. Run it on a **copy** of the database, never on the
|
||||
live file; copy `-wal`/`-shm` along with it when the app is running.
|
||||
|
||||
```python
|
||||
import sqlite3, sys, hashlib
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")]
|
||||
for t in ("dinners", "week_plans", "schema_migrations"):
|
||||
print(t, c.execute(f"select count(*) from {t}").fetchone()[0])
|
||||
h = hashlib.sha256()
|
||||
for t in tabs:
|
||||
for row in c.execute(f"select * from \"{t}\" order by rowid"):
|
||||
h.update(repr(row).encode())
|
||||
print("tables", len(tabs), "content-sha256", h.hexdigest()[:16])
|
||||
print("integrity", c.execute("pragma integrity_check").fetchone()[0])
|
||||
```
|
||||
|
||||
```sh
|
||||
rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck
|
||||
sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/" && sudo chown -R "$USER" /tmp/mqcheck
|
||||
python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db
|
||||
```
|
||||
|
||||
Before and after must match exactly.
|
||||
|
||||
## Restore photos (media)
|
||||
|
||||
Recipe and dinner photos live in `media/` in the data volume and are
|
||||
backed up nightly to `joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/`
|
||||
(see "Media — nightly rsync"). The backup service mirrors with
|
||||
`rsync --delete`. **An `rsync --delete` run must never see an empty or
|
||||
partial `media/`**: it would delete the NAS copy you are about to restore
|
||||
from. Stopping the timer is not enough — it does not stop a run that is
|
||||
already in flight, and the service can still be started by hand. So stop
|
||||
**both** the timer and the service, confirm no rsync is running, and do
|
||||
not start the service until the restore is verified.
|
||||
|
||||
Run from `/home/overseer/mealquest`:
|
||||
|
||||
```sh
|
||||
M=/var/lib/docker/volumes/mealquest-data/_data/media
|
||||
NAS=/volume1/homes/Joshua/mealquest-backups/media
|
||||
SSH="ssh -i deploy/secrets/litestream_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/mealquest/known_hosts -o StrictHostKeyChecking=yes"
|
||||
|
||||
# 1. Stop the nightly mirror: the timer AND any run in flight.
|
||||
sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service
|
||||
|
||||
# 2. Pre-check — all three must hold before media/ is touched. If any
|
||||
# fails, stop here and find out why.
|
||||
systemctl is-active mealquest-media-backup.timer # inactive
|
||||
systemctl is-active mealquest-media-backup.service # inactive (or failed)
|
||||
pgrep -a -u mealquest rsync || echo "no rsync" # no rsync
|
||||
sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l"
|
||||
# ^ note this NAS file count; it must be non-zero and is the target below.
|
||||
|
||||
# 3. Stop the app (no writes into media/ meanwhile).
|
||||
docker compose -f deploy/compose.yaml stop mealquest
|
||||
|
||||
# 4. Recreate the directory if it is gone, owned by the app user.
|
||||
sudo install -d -o 10001 -g 10001 -m 0755 "$M"
|
||||
|
||||
# 5. Copy back from the NAS as uid 10001's files. No --delete in this
|
||||
# direction: the NAS copy is the source and is only read.
|
||||
sudo rsync -a --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/"
|
||||
|
||||
# 6. Post-condition — before anything can mirror again:
|
||||
sudo find "$M" -type f | wc -l # >= the NAS count from step 2
|
||||
sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l" # unchanged from step 2
|
||||
|
||||
# 7. Start the app, then the timer again. Only now may the service run.
|
||||
docker compose -f deploy/compose.yaml up -d
|
||||
curl -fsS http://10.60.1.14:8090/health
|
||||
sudo systemctl start mealquest-media-backup.timer
|
||||
systemctl is-active mealquest-media-backup.timer # active
|
||||
```
|
||||
|
||||
The local count may exceed the NAS count only if `media/` still held
|
||||
files the NAS never received (photos added since the last nightly run);
|
||||
the next nightly run then copies them up. If the local count is lower,
|
||||
the copy did not complete: leave the timer stopped and re-run step 5.
|
||||
|
||||
When the database is restored too, restore it first ("Restore the
|
||||
database"), then the media, so every photo the database refers to is
|
||||
present. This media restore has not been run against production yet; the
|
||||
backup direction was checked on 2026-10-05 (see "Media backup checks").
|
||||
|
||||
## Restore drill record — 2026-10-05
|
||||
|
||||
Drill on 10.60.1.14 against the live (pre-launch, empty-household)
|
||||
database, Litestream 0.5.17. Commands as in "Restore the database";
|
||||
output trimmed only of compose progress lines.
|
||||
|
||||
Replica before the drill (`ltx -level all`; level 9 is the full snapshot):
|
||||
|
||||
```
|
||||
level min_txid max_txid size created
|
||||
0 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z
|
||||
1 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
1 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z
|
||||
2 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
2 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z
|
||||
3 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
9 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
```
|
||||
|
||||
1. `docker compose -f deploy/compose.yaml down` — both containers stopped
|
||||
and removed. The volume then held `mealquest.db`,
|
||||
`.mealquest.db-litestream/` and `media/`; the clean close had
|
||||
checkpointed and removed `-wal` and `-shm`.
|
||||
2. Before (copy of the stopped database):
|
||||
|
||||
```
|
||||
dinners 0
|
||||
week_plans 0
|
||||
schema_migrations 7
|
||||
tables 26 content-sha256 836e230e16c58dc7
|
||||
integrity ok
|
||||
```
|
||||
|
||||
3. Moved aside to `/var/tmp/mealquest-restore-aside-20261005`:
|
||||
`mealquest.db` and `.mealquest.db-litestream` (no `-wal`/`-shm` existed
|
||||
after the clean stop; the loop moves them whenever they do). Volume
|
||||
afterwards: `media/` only.
|
||||
4. `docker compose -f deploy/compose.yaml run --rm --no-deps litestream restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db`
|
||||
— volume afterwards: `mealquest.db` (258048 bytes) and `media/`; no
|
||||
`-wal`, no `-shm`.
|
||||
5. After (copy of the restored file, before the app started):
|
||||
|
||||
```
|
||||
dinners 0
|
||||
week_plans 0
|
||||
schema_migrations 7
|
||||
tables 26 content-sha256 836e230e16c58dc7
|
||||
integrity ok
|
||||
```
|
||||
|
||||
6. `docker compose -f deploy/compose.yaml up -d` — `mealquest-mealquest-1`
|
||||
healthy, `mealquest-litestream-1` up; `GET /health` → `200`. Counts on a
|
||||
copy of the running database (`mealquest.db`, `-wal`, `-shm`) were
|
||||
identical to the above.
|
||||
7. Litestream resumed against the restored database:
|
||||
|
||||
```
|
||||
level=INFO msg="detected database behind replica" db=mealquest.db db_txid=0000000000000000 replica_txid=0000000000000003
|
||||
level=INFO msg="fetched latest L0 file from replica" db=mealquest.db min_txid=0000000000000003 max_txid=0000000000000003
|
||||
level=INFO msg="compaction complete" db=mealquest.db level=1 txid.min=0000000000000004 txid.max=0000000000000004 size=14110
|
||||
```
|
||||
|
||||
Replica after the drill (`ltx -level all`):
|
||||
|
||||
```
|
||||
level min_txid max_txid size created
|
||||
0 0000000000000004 0000000000000004 14110 2026-10-05T23:27:21Z
|
||||
1 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
1 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z
|
||||
1 0000000000000004 0000000000000004 14110 2026-10-05T23:27:21Z
|
||||
2 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
2 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z
|
||||
3 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
9 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z
|
||||
```
|
||||
|
||||
The first drill earlier the same day (before `-level all` was recorded)
|
||||
gave the same before/after result: checksum `836e230e16c58dc7`, 26 tables,
|
||||
integrity ok.
|
||||
|
||||
### Media backup checks — 2026-10-05
|
||||
|
||||
`sudo systemctl start mealquest-media-backup.service` on 10.60.1.14:
|
||||
|
||||
| Case | Result |
|
||||
| --- | --- |
|
||||
| Normal run | `Result=success`; journal: `synced /run/mealquest-data/media to joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/ as uid 10001` |
|
||||
| A test file added | `Number of regular files transferred: 1`; removing it then gave `Number of deleted files: 1 (reg: 1)` |
|
||||
| `media` replaced by a symlink to `/etc` | `Result=exit-code`, status 1; `/run/mealquest-data/media is a symlink to /etc; refusing to back it up` |
|
||||
| `media` removed | `Result=exit-code`, status 1; `media directory /run/mealquest-data/media is missing` |
|
||||
| `sudo -u mealquest` writing into the volume via `/var/lib/docker/...` | `Permission denied` — uid 10001 cannot traverse `/var/lib/docker` (mode 0710 root) |
|
||||
|
||||
## Secret rotation
|
||||
|
||||
Rotate a secret when it may have leaked, when someone with access leaves,
|
||||
or on the owner's schedule. Never paste a secret into a ticket, chat or
|
||||
this file.
|
||||
|
||||
### App key (`deploy/secrets/secret_key`)
|
||||
|
||||
The app key encrypts every stored secret: the `_ct` columns
|
||||
`ai_settings.api_key_ct` (LLM API key), `recipe_search_settings.api_key_ct`
|
||||
(recipe-search API key), `integrations_google.refresh_token_ct` (Google
|
||||
Calendar refresh token) and `chat_settings.webhook_url_ct` (Google Chat
|
||||
webhook URL). **Rotating it re-encrypts nothing**: a value encrypted with
|
||||
the old key can no longer be read. A `mealquest rotate-key` helper that
|
||||
re-encrypts in place is planned but does not exist yet.
|
||||
|
||||
**What is recoverable today.** The service currently exposes only
|
||||
household settings; no shipped flow writes or re-enters any of these four
|
||||
secrets. The re-entry flows are planned: the LLM key in Settings → AI
|
||||
assistant (the AI settings feature), the recipe-search key (Feature 10),
|
||||
Google Calendar reconnect (Feature 16) and the Google Chat webhook
|
||||
(Feature 19). So today the rotation is safe **only if no `_ct` value is
|
||||
stored**, which the pre-check below proves. If the pre-check finds a
|
||||
stored value, do not rotate: it could not be re-entered afterwards. Keep
|
||||
the current key and wait for the matching flow (or the `rotate-key`
|
||||
helper).
|
||||
|
||||
**The pre-check and the key replacement run with the whole stack
|
||||
stopped**, from the pre-check until the new key is in place. A raw copy of
|
||||
`mealquest.db` and `mealquest.db-wal` taken while the stack runs is **not**
|
||||
a consistent snapshot: the two files are copied one after the other, and a
|
||||
checkpoint (by the app or by Litestream) between the two copies can move
|
||||
committed pages out of the WAL into `mealquest.db` after `mealquest.db` was
|
||||
already copied, then reset the WAL before it is copied. The copy then
|
||||
lacks those commits — possibly a stored `_ct` value — and the pre-check
|
||||
reports a false 0. A running app could also store a `_ct` value after a
|
||||
correct pre-check and before the key is replaced. Stopping both containers
|
||||
removes every writer (Litestream also writes and checkpoints), and the
|
||||
clean close checkpoints the WAL into `mealquest.db`, so the file alone is
|
||||
complete.
|
||||
|
||||
Run from `/home/overseer/mealquest`:
|
||||
|
||||
```sh
|
||||
D=/var/lib/docker/volumes/mealquest-data/_data
|
||||
|
||||
# 1. Stop the whole stack (app and Litestream) and leave it stopped until
|
||||
# step 4. Nothing may hold the database open.
|
||||
sudo systemctl stop mealquest
|
||||
sudo ls -la "$D" # mealquest.db and media/ only: no -wal, no -shm; if
|
||||
# either exists, the close was not clean — stop here
|
||||
|
||||
# 2. Pre-check, on a copy of the now-quiescent database.
|
||||
rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck
|
||||
sudo cp -a "$D/mealquest.db" /tmp/mqcheck/ && sudo chown -R "$USER" /tmp/mqcheck
|
||||
python3 - /tmp/mqcheck/mealquest.db <<'PY'
|
||||
import sqlite3, sys
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
for t, col in (("ai_settings", "api_key_ct"), ("recipe_search_settings", "api_key_ct"),
|
||||
("integrations_google", "refresh_token_ct"), ("chat_settings", "webhook_url_ct")):
|
||||
print(t, col, c.execute(f"select count(*) from {t} where {col} is not null").fetchone()[0])
|
||||
PY
|
||||
rm -rf /tmp/mqcheck
|
||||
# every count must be 0; otherwise start the stack again on the current
|
||||
# key (sudo systemctl start mealquest) and stop here
|
||||
|
||||
# 3. Replace the key, still with the stack stopped.
|
||||
sudo cp -a deploy/secrets/secret_key /var/tmp/secret_key.old # keep until verified, then shred
|
||||
head -c 32 /dev/urandom | base64 | sudo tee deploy/secrets/secret_key >/dev/null
|
||||
sudo chown 10001:10001 deploy/secrets/secret_key
|
||||
sudo chmod 600 deploy/secrets/secret_key
|
||||
|
||||
# 4. Start the stack on the new key and check health.
|
||||
sudo systemctl start mealquest
|
||||
docker compose -f deploy/compose.yaml ps
|
||||
curl -fsS http://10.60.1.14:8090/health
|
||||
```
|
||||
|
||||
Then sign in and open household settings to confirm the service runs on
|
||||
the new key. Finally `sudo shred -u /var/tmp/secret_key.old`. Once the
|
||||
planned flows ship, this section gains a step per secret to re-enter it
|
||||
and confirm it works.
|
||||
|
||||
### OIDC client secret (`deploy/secrets/oidc_client_secret`)
|
||||
|
||||
In Authentik, open the provider `mealquest` and regenerate the Client
|
||||
Secret. Write it to the file (same owner and mode as above) and recreate
|
||||
the service:
|
||||
|
||||
```sh
|
||||
printf '%s\n' '<new Authentik client secret>' | sudo tee deploy/secrets/oidc_client_secret >/dev/null
|
||||
sudo chown 10001:10001 deploy/secrets/oidc_client_secret
|
||||
sudo chmod 600 deploy/secrets/oidc_client_secret
|
||||
docker compose -f deploy/compose.yaml up -d --force-recreate mealquest
|
||||
```
|
||||
|
||||
Sign in at <https://mealquest.cubecraftlabs.com> to confirm; sign-ins
|
||||
fail between regenerating the secret in Authentik and recreating the
|
||||
service.
|
||||
|
||||
### LLM API key
|
||||
|
||||
Stored encrypted in the database, not in `deploy/`. In the app, open
|
||||
**Settings → AI assistant**, choose **Replace**, paste the new key and
|
||||
**Verify**, and check the verified state is shown. Then revoke the
|
||||
old key in the provider's console. (The AI assistant settings page is
|
||||
delivered by its own feature; until it ships there is no stored LLM key
|
||||
to rotate.)
|
||||
|
||||
### VAPID keys (Web Push)
|
||||
|
||||
Planned with Web Push (Feature 19): the public key goes in
|
||||
`deploy/.env` as `MEALQUEST_VAPID_PUBLIC_KEY`, the private key in
|
||||
`deploy/secrets/vapid_private_key` (owner `10001:10001`, mode 0600), and
|
||||
the pair is generated with `cmd/vapidgen`. **Changing the VAPID pair
|
||||
invalidates every push subscription**: each device must turn
|
||||
notifications on again (`/settings/notifications`) before it receives
|
||||
pushes. Rotate only when the private key may have leaked, write both
|
||||
halves, recreate the service with `--force-recreate mealquest`, and tell
|
||||
the household to re-enable notifications on each device.
|
||||
|
||||
### Litestream / NAS key (`deploy/secrets/litestream_key`)
|
||||
|
||||
Generate a new keypair as in "Database — Litestream", add the new public
|
||||
key to `joshua@10.60.1.50:~/.ssh/authorized_keys`, replace the file
|
||||
(owner `10001:10001`, mode 0600), run
|
||||
`docker compose -f deploy/compose.yaml up -d --force-recreate litestream`
|
||||
and `sudo systemctl start mealquest-media-backup.service`, check both
|
||||
(`litestream ltx` and the journal), then remove the old public key from
|
||||
the NAS.
|
||||
|
||||
## Context records
|
||||
|
||||
The confirmed values in this runbook (port 8090, checkout path, Authentik
|
||||
group `mealquest-admin`, DNS, firewall, backup target) are also recorded in
|
||||
the CTXD project `mealquest`: `RUNBOOKS.MD` (deployment procedure) and the
|
||||
open questions in `CONTEXT.MD`, updated for Feature 07 task 876134bc.
|
||||
@@ -1,100 +0,0 @@
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
|
||||
# then start through systemd:
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml build
|
||||
# sudo systemctl restart mealquest
|
||||
#
|
||||
# The services use restart: "no" on purpose: Docker must not start them
|
||||
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
|
||||
# owns start and stop and requires mealquest-firewall.service first.
|
||||
#
|
||||
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
|
||||
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
|
||||
# with their host owner and mode, so each file in deploy/secrets/ must be
|
||||
# owned by the container user 10001:10001 with mode 0600.
|
||||
name: mealquest
|
||||
|
||||
services:
|
||||
mealquest:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
user: "10001:10001"
|
||||
restart: "no"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: true
|
||||
# Fixed by this file's container shape (listen port, volume, secret
|
||||
# mounts); deploy/.env must not set them.
|
||||
environment:
|
||||
MEALQUEST_HTTP_ADDR: ":8080"
|
||||
MEALQUEST_DATA_DIR: /data
|
||||
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
||||
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||
secrets:
|
||||
- secret_key
|
||||
- oidc_client_secret
|
||||
volumes:
|
||||
- mealquest-data:/data
|
||||
ports:
|
||||
- "${MEALQUEST_BIND_ADDRESS:?set MEALQUEST_BIND_ADDRESS in deploy/.env}:8090:8080"
|
||||
networks:
|
||||
- mealquest
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
# Litestream streams the database to the Synology NAS over SFTP
|
||||
# (config: deploy/litestream.yml). The SFTP private key
|
||||
# deploy/secrets/litestream_key is a dedicated keypair, owned by
|
||||
# 10001:10001 with mode 0600 like the other secrets.
|
||||
#
|
||||
# The data volume is mounted read-write on purpose: Litestream writes its
|
||||
# tracking tables into the database and checkpoints the WAL. It runs as
|
||||
# the app's own user (owner of the volume) with a read-only root
|
||||
# filesystem, no privilege escalation and no Linux capabilities.
|
||||
#
|
||||
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
|
||||
# stack, move the database with its -wal and -shm aside, restore, check).
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17
|
||||
user: "10001:10001"
|
||||
restart: "no"
|
||||
command: ["replicate", "-config", "/etc/litestream.yml"]
|
||||
depends_on:
|
||||
mealquest:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- mealquest-data:/data
|
||||
- ./litestream.yml:/etc/litestream.yml:ro
|
||||
- ./secrets/litestream_key:/etc/litestream/sftp_key:ro
|
||||
networks:
|
||||
- mealquest
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
secrets:
|
||||
secret_key:
|
||||
file: ./secrets/secret_key
|
||||
oidc_client_secret:
|
||||
file: ./secrets/oidc_client_secret
|
||||
|
||||
volumes:
|
||||
mealquest-data:
|
||||
name: mealquest-data
|
||||
|
||||
networks:
|
||||
mealquest:
|
||||
name: mealquest
|
||||
@@ -1,6 +0,0 @@
|
||||
# Host keys of the Synology NAS (10.60.1.50), the backup target. Installed
|
||||
# root-owned with mode 0644 as /etc/mealquest/known_hosts and pinned by the
|
||||
# media backup (UserKnownHostsFile, StrictHostKeyChecking=yes). The ECDSA
|
||||
# key is also pinned in deploy/litestream.yml (host-key).
|
||||
10.60.1.50 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKHxIfgjavBABMRE5GW/EFY4C4gO6mo0sKbhkUW2NcAo
|
||||
10.60.1.50 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY=
|
||||
@@ -1,31 +0,0 @@
|
||||
# Litestream continuous backup of the MealQuest SQLite database (Feature
|
||||
# 07). The ``litestream`` compose service mounts the same named volume as
|
||||
# the app and streams every committed change to the Synology NAS over
|
||||
# SFTP, so the backup lives outside the Docker host.
|
||||
#
|
||||
# Nothing here is secret. The SFTP private key is a dedicated, git-ignored
|
||||
# keypair (deploy/secrets/litestream_key) mounted read-only by compose at
|
||||
# /etc/litestream/sftp_key; its public half is in
|
||||
# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the NAS's
|
||||
# ECDSA host key, the algorithm Litestream's SSH client negotiates with
|
||||
# this NAS.
|
||||
#
|
||||
# The Synology SFTP server is chrooted to the shared folders, so the
|
||||
# directory seen over SSH as /volume1/homes/Joshua/mealquest-backups is
|
||||
# /homes/Joshua/mealquest-backups over SFTP.
|
||||
|
||||
# Take a full snapshot daily and keep a week of snapshots plus the
|
||||
# incremental changes between them (point-in-time restore window).
|
||||
snapshot:
|
||||
interval: 24h
|
||||
retention: 168h
|
||||
|
||||
dbs:
|
||||
- path: /data/mealquest.db
|
||||
replicas:
|
||||
- type: sftp
|
||||
host: 10.60.1.50
|
||||
user: joshua
|
||||
path: /homes/Joshua/mealquest-backups
|
||||
key-path: /etc/litestream/sftp_key
|
||||
host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY=
|
||||
@@ -1,22 +0,0 @@
|
||||
# Applies the MealQuest DOCKER-USER rules (/usr/local/sbin/mealquest-firewall).
|
||||
#
|
||||
# PartOf= re-runs this unit whenever docker.service is stopped or restarted,
|
||||
# and WantedBy=docker.service starts it whenever docker.service starts. Both
|
||||
# are one-way: a failure here fails this unit visibly and never fails
|
||||
# docker.service.
|
||||
#
|
||||
# Start MealQuest only after this unit has succeeded:
|
||||
# systemctl start mealquest-firewall && docker compose up -d
|
||||
[Unit]
|
||||
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
PartOf=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/mealquest-firewall
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target docker.service
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Restrict the MealQuest published host port (10.60.1.14:8090) to Traefik
|
||||
# (10.60.1.33) and the Docker host itself (10.60.1.14); drop every other
|
||||
# source.
|
||||
#
|
||||
# DOCKER-USER runs after Docker's DNAT, so the destination there is the
|
||||
# container address and port, not the published ones. Match the original
|
||||
# destination address and port with conntrack instead of -d/--dport.
|
||||
#
|
||||
# The rules must sit at the top of DOCKER-USER, ahead of any other project's
|
||||
# rules (an earlier RETURN or DROP would decide MealQuest traffic first).
|
||||
# The port is never left open while the rules are updated:
|
||||
#
|
||||
# 1. If the chain already starts with exactly the three rules below and
|
||||
# holds no other mealquest-firewall rule, change nothing.
|
||||
# 2. Otherwise insert the DROP at position 1 first, so the port is closed
|
||||
# to every source (including Traefik, briefly) from here on.
|
||||
# 3. Delete every other rule tagged mealquest-firewall, by rule number,
|
||||
# highest first.
|
||||
# 4. Insert the two RETURN rules above the DROP.
|
||||
#
|
||||
# Re-runs leave the same ordered rules at positions 1-3 and no duplicates.
|
||||
# Installed as /usr/local/sbin/mealquest-firewall.
|
||||
set -eu
|
||||
|
||||
chain=DOCKER-USER
|
||||
tag="-m comment --comment mealquest-firewall"
|
||||
match="-m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL"
|
||||
|
||||
# The three rules as iptables -S prints them, in their required order.
|
||||
want="-A $chain -s 10.60.1.33/32 $match $tag -j RETURN
|
||||
-A $chain -s 10.60.1.14/32 $match $tag -j RETURN
|
||||
-A $chain $match $tag -j DROP"
|
||||
|
||||
rules=$(iptables -w -S "$chain" | grep -e '^-A ' || true)
|
||||
head=$(printf '%s\n' "$rules" | head -n 3)
|
||||
tagged=$(printf '%s\n' "$rules" | grep -c -e '--comment mealquest-firewall ' || true)
|
||||
if [ "$head" = "$want" ] && [ "$tagged" -eq 3 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
iptables -w -I "$chain" 1 $match $tag -j DROP
|
||||
|
||||
iptables -w -S "$chain" | grep -e '^-A ' |
|
||||
awk '/--comment mealquest-firewall / { print NR }' |
|
||||
sort -rn | while read -r num; do
|
||||
if [ "$num" -ne 1 ]; then
|
||||
iptables -w -D "$chain" "$num"
|
||||
fi
|
||||
done
|
||||
|
||||
iptables -w -I "$chain" 1 -s 10.60.1.14/32 $match $tag -j RETURN
|
||||
iptables -w -I "$chain" 1 -s 10.60.1.33/32 $match $tag -j RETURN
|
||||
@@ -1,29 +0,0 @@
|
||||
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
|
||||
# Triggered by mealquest-media-backup.timer; run by hand with
|
||||
# systemctl start mealquest-media-backup
|
||||
# and read the result with
|
||||
# journalctl -u mealquest-media-backup
|
||||
#
|
||||
# The script runs as the app's user (10001, host user "mealquest") with no
|
||||
# capabilities. systemd, as root, only bind-mounts the data volume's root
|
||||
# (a root-owned path the app cannot replace) read-only at
|
||||
# /run/mealquest-data and copies the NAS key into the credentials directory.
|
||||
[Unit]
|
||||
Description=Back up MealQuest media to the Synology NAS
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/mealquest-media-backup
|
||||
User=mealquest
|
||||
Group=mealquest
|
||||
LoadCredential=nas_key:/home/overseer/mealquest/deploy/secrets/litestream_key
|
||||
BindReadOnlyPaths=/var/lib/docker/volumes/mealquest-data/_data:/run/mealquest-data
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Nightly copy of the MealQuest media directory (recipe and dinner photos)
|
||||
# to the Synology NAS. The database itself is replicated continuously by
|
||||
# Litestream; this covers the files beside it.
|
||||
#
|
||||
# Runs as the unprivileged app user (10001, host user "mealquest"), never
|
||||
# root: mealquest-media-backup.service bind-mounts the data volume
|
||||
# read-only at /run/mealquest-data and hands the NAS key in as a systemd
|
||||
# credential. Anything the app could plant in the volume is therefore read
|
||||
# with the app's own permissions only.
|
||||
#
|
||||
# The script changes into the media directory and checks that the physical
|
||||
# path is the expected one, so a media path that is a symlink is refused.
|
||||
# rsync then copies "./" — the directory the shell is already in — so
|
||||
# swapping the path afterwards cannot redirect it. Symlinks inside the
|
||||
# directory are copied as links (-a), never followed.
|
||||
#
|
||||
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
|
||||
# is missing the job fails; it is never treated as "nothing to back up".
|
||||
#
|
||||
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
|
||||
# a slash, so --delete removes files from that directory and nothing
|
||||
# outside it. Re-runs transfer only what changed. Output goes to the
|
||||
# journal via mealquest-media-backup.service.
|
||||
set -eu
|
||||
|
||||
src=/run/mealquest-data/media
|
||||
dest=joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/
|
||||
key=$CREDENTIALS_DIRECTORY/nas_key
|
||||
known_hosts=/etc/mealquest/known_hosts
|
||||
|
||||
if ! cd "$src"; then
|
||||
echo "mealquest-media-backup: media directory $src is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(pwd -P)" != "$src" ]; then
|
||||
echo "mealquest-media-backup: $src is a symlink to $(pwd -P); refusing to back it up" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rsync -a --delete --stats \
|
||||
-e "ssh -F none -i $key -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=$known_hosts -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes" \
|
||||
./ "$dest"
|
||||
echo "mealquest-media-backup: synced $src to $dest as uid $(id -u)"
|
||||
@@ -1,11 +0,0 @@
|
||||
# Nightly trigger for mealquest-media-backup.service. Persistent= runs a
|
||||
# missed backup at the next boot.
|
||||
[Unit]
|
||||
Description=Nightly MealQuest media backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,22 +0,0 @@
|
||||
# Owns the MealQuest compose stack (deploy/compose.yaml). The services use
|
||||
# restart: "no", so Docker never starts them on its own; this unit is the
|
||||
# only start path.
|
||||
#
|
||||
# Requires= and After=mealquest-firewall.service start the stack only after
|
||||
# the DOCKER-USER rules are applied, and not at all if that unit fails.
|
||||
# PartOf=docker.service propagates a Docker stop or restart here, so a
|
||||
# Docker restart re-applies the firewall before the stack comes back.
|
||||
[Unit]
|
||||
Description=MealQuest compose stack
|
||||
Requires=mealquest-firewall.service
|
||||
After=mealquest-firewall.service docker.service
|
||||
PartOf=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/docker compose -f /home/overseer/mealquest/deploy/compose.yaml up -d
|
||||
ExecStop=/usr/bin/docker compose -f /home/overseer/mealquest/deploy/compose.yaml down
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+31
-5
@@ -208,8 +208,37 @@ automatic** (D-021).
|
||||
|
||||
- Dinners are grounded in real recipe pages found through recipe search
|
||||
(`searxng` or `brave`, per `recipe_search_settings`).
|
||||
- Fetching is **SSRF-safe**.
|
||||
- Recipe data is parsed from schema.org `Recipe` JSON-LD.
|
||||
- Fetching is **SSRF-safe** (`recipes.Fetcher`): https:// only, at every
|
||||
redirect hop. Its dialer resolves the host once and refuses unless every
|
||||
answer is public — every non-globally-reachable IPv4 special-purpose
|
||||
range (loopback, RFC 1918, link-local, carrier-grade NAT, documentation
|
||||
TEST-NETs, benchmarking 198.18/15, multicast, reserved and more) is
|
||||
refused, IPv4-mapped forms included, and IPv6 must be global unicast
|
||||
(2000::/3) outside documentation, 6to4, Teredo and other special ranges —
|
||||
then dials the address it checked, so a
|
||||
rebinding DNS answer cannot swap in a private one. Keep-alives and
|
||||
environment proxies are off, so every request dials and checks afresh. At
|
||||
most 3 redirects; a body over 2 MB is an error, never truncated; 10 s for
|
||||
the whole fetch; a descriptive `User-Agent`; requests to one host at least
|
||||
1 s apart, counted from requests actually sent (an abandoned wait holds no
|
||||
slot); 2xx pages cached by URL for an hour (64 pages, oldest evicted), and
|
||||
callers get their own copy of a cached body.
|
||||
- Recipe data is parsed from schema.org `Recipe` JSON-LD
|
||||
(`recipes.Extract`): a small targeted scan of the page's
|
||||
`application/ld+json` scripts and `og:site_name` (no HTML parser
|
||||
dependency), finding the Recipe node at a script's top level, in a
|
||||
top-level array or in an `@graph`, with `@type` a string or an array of
|
||||
terms or absolute schema.org IRIs (`https://schema.org/Recipe`). An image
|
||||
or publisher given as an `{"@id": ...}` reference resolves to the node
|
||||
with that `@id` anywhere in the page's JSON-LD. The largest image (by ImageObject width × height; the first of equals) is
|
||||
resolved to an absolute URL; instructions — one string of lines, or a
|
||||
list of strings, HowToSteps and HowToSections — are flattened in order;
|
||||
ISO-8601 prep/cook/total times become minutes, total derived as prep +
|
||||
cook when only those are given; the site name is the publisher's, else
|
||||
`og:site_name`, else the host. A page with no Recipe is `ErrNoRecipe`,
|
||||
with two is `ErrManyRecipes`; a shape schema.org does not allow — such as
|
||||
a non-ISO-8601 duration like "20 minutes" or an empty one, or an image
|
||||
`@id` no node has — is `ErrMalformedRecipe`, never guessed at.
|
||||
- Photos are cached locally (`photos`) and processed with
|
||||
`golang.org/x/image`.
|
||||
|
||||
@@ -260,9 +289,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
hard-coded hex. Self-hosted Bagel Fat One and Figtree.
|
||||
- Tests: Vitest, Testing Library, jsdom.
|
||||
- Offline cache for the current plan, shopping list and recipes.
|
||||
- Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage",
|
||||
"cache"`, wiping the offline marker, the service worker and its caches; the
|
||||
worker leaves that request to the browser, as it does every network-only one.
|
||||
|
||||
| Route | Screen |
|
||||
| --- | --- |
|
||||
|
||||
@@ -6,7 +6,6 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/oklog/ulid/v2 v2.1.2
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
@@ -21,8 +21,6 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
|
||||
@@ -327,13 +327,8 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// logout ends the session, clears the cookie and sends the browser to the
|
||||
// provider's end_session_endpoint to end the provider session as well.
|
||||
// Clear-Site-Data wipes this origin's storage and caches — the PWA's
|
||||
// verified-session marker in localStorage and the service worker's cached API
|
||||
// and media responses — so an offline visit after logout cannot reopen
|
||||
// household data.
|
||||
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Clear-Site-Data", `"storage", "cache"`)
|
||||
if err := a.sessions.EndSession(w, r); err != nil {
|
||||
a.logger.ErrorContext(r.Context(), "end session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
@@ -440,9 +440,6 @@ func TestLogout(t *testing.T) {
|
||||
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
|
||||
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
|
||||
}
|
||||
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
|
||||
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
|
||||
}
|
||||
|
||||
rows := h.rows(t)
|
||||
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
|
||||
@@ -464,9 +461,6 @@ func TestLogoutWithoutSession(t *testing.T) {
|
||||
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
|
||||
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
|
||||
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutIsPostOnly(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoRecipe is returned for a page with no schema.org Recipe node in
|
||||
// its JSON-LD.
|
||||
ErrNoRecipe = errors.New("recipes: page has no schema.org Recipe")
|
||||
// ErrManyRecipes is returned for a page with more than one Recipe node:
|
||||
// which one the page is about is not knowable.
|
||||
ErrManyRecipes = errors.New("recipes: page has more than one schema.org Recipe")
|
||||
// ErrMalformedRecipe is returned when the page's JSON-LD, or a field of
|
||||
// its Recipe node, is not in a shape schema.org allows.
|
||||
ErrMalformedRecipe = errors.New("recipes: malformed schema.org Recipe")
|
||||
)
|
||||
|
||||
// Recipe is the schema.org Recipe extracted from a recipe page.
|
||||
type Recipe struct {
|
||||
// Title is the recipe's name.
|
||||
Title string
|
||||
// SiteName is the publisher's name, else the page's og:site_name, else
|
||||
// its host without a leading "www.".
|
||||
SiteName string
|
||||
// ImageURL is the largest image, absolute; empty when the recipe has
|
||||
// none.
|
||||
ImageURL string
|
||||
Ingredients []string
|
||||
// Steps are the instructions in order, HowToSection groups flattened.
|
||||
Steps []string
|
||||
// PrepMinutes, CookMinutes and TotalMinutes are nil when not given.
|
||||
// TotalMinutes is prep plus cook when the page gives both but no total.
|
||||
PrepMinutes *int
|
||||
CookMinutes *int
|
||||
TotalMinutes *int
|
||||
// Yield is recipeYield as text; empty when not given.
|
||||
Yield string
|
||||
// JSONLD is the Recipe node as found.
|
||||
JSONLD json.RawMessage
|
||||
}
|
||||
|
||||
// Extract finds the schema.org Recipe in a page's application/ld+json
|
||||
// scripts. Recipe nodes are found at the top level of a script, in a
|
||||
// top-level array, or in an @graph. An image or publisher given as an
|
||||
// {"@id": ...} reference resolves to the node with that @id found in the
|
||||
// same places, in any of the page's scripts. pageURL is the page's own
|
||||
// absolute URL; relative image URLs resolve against it.
|
||||
func Extract(page []byte, pageURL string) (Recipe, error) {
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil || !base.IsAbs() || base.Host == "" {
|
||||
return Recipe{}, fmt.Errorf("recipes: page URL %q is not absolute", pageURL)
|
||||
}
|
||||
doc := scanHTML(string(page))
|
||||
g := graph{ids: map[string][]map[string]any{}}
|
||||
for i, script := range doc.jsonLD {
|
||||
v, err := decodeJSON(script)
|
||||
if err != nil {
|
||||
return Recipe{}, fmt.Errorf("%w: JSON-LD script %d: %v", ErrMalformedRecipe, i+1, err)
|
||||
}
|
||||
g.collect(v)
|
||||
}
|
||||
nodes := g.recipes
|
||||
switch len(nodes) {
|
||||
case 0:
|
||||
return Recipe{}, ErrNoRecipe
|
||||
case 1:
|
||||
default:
|
||||
return Recipe{}, fmt.Errorf("%w: found %d", ErrManyRecipes, len(nodes))
|
||||
}
|
||||
r, err := recipeFrom(nodes[0], g, base, doc.ogSiteName)
|
||||
if err != nil {
|
||||
return Recipe{}, fmt.Errorf("%w: %v", ErrMalformedRecipe, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func decodeJSON(s string) (any, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(s))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return nil, errors.New("trailing data after JSON value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// graph holds the page's JSON-LD nodes: the Recipe nodes, and every node
|
||||
// with an @id by that @id.
|
||||
type graph struct {
|
||||
recipes []map[string]any
|
||||
ids map[string][]map[string]any
|
||||
}
|
||||
|
||||
// collect gathers the nodes in v: v itself, the elements of an array, and
|
||||
// the members of an @graph. A bare {"@id": ...} reference is not a node.
|
||||
func (g *graph) collect(v any) {
|
||||
switch n := v.(type) {
|
||||
case []any:
|
||||
for _, e := range n {
|
||||
g.collect(e)
|
||||
}
|
||||
case map[string]any:
|
||||
if hasType(n, "Recipe") {
|
||||
g.recipes = append(g.recipes, n)
|
||||
}
|
||||
if id, ok := n["@id"].(string); ok && len(n) > 1 {
|
||||
g.ids[id] = append(g.ids[id], n)
|
||||
}
|
||||
if members, ok := n["@graph"]; ok {
|
||||
g.collect(members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve returns the node a bare {"@id": ...} reference names, and v
|
||||
// itself when v is not a reference. found is false for a reference to no
|
||||
// node; a reference to more than one node is an error.
|
||||
func (g graph) resolve(v any) (node any, found bool, err error) {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok || len(m) != 1 {
|
||||
return v, true, nil
|
||||
}
|
||||
id, ok := m["@id"].(string)
|
||||
if !ok {
|
||||
return v, true, nil
|
||||
}
|
||||
switch nodes := g.ids[id]; len(nodes) {
|
||||
case 0:
|
||||
return v, false, nil
|
||||
case 1:
|
||||
return nodes[0], true, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("@id %q names %d nodes", id, len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// hasType reports whether node's @type, a string or an array of strings,
|
||||
// names the schema.org type typ, as a term or an absolute schema.org IRI.
|
||||
func hasType(node map[string]any, typ string) bool {
|
||||
switch t := node["@type"].(type) {
|
||||
case string:
|
||||
return isSchemaType(t, typ)
|
||||
case []any:
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok && isSchemaType(s, typ) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSchemaType(s, typ string) bool {
|
||||
return s == typ || s == "https://schema.org/"+typ || s == "http://schema.org/"+typ
|
||||
}
|
||||
|
||||
func recipeFrom(n map[string]any, g graph, base *url.URL, ogSiteName string) (Recipe, error) {
|
||||
var r Recipe
|
||||
var err error
|
||||
name, ok := n["name"].(string)
|
||||
if r.Title = cleanText(name); !ok || r.Title == "" {
|
||||
return Recipe{}, errors.New("name is missing")
|
||||
}
|
||||
if r.SiteName, err = siteName(n["publisher"], g, ogSiteName, base); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.ImageURL, err = imageURL(n["image"], g, base); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.Ingredients, err = ingredients(n["recipeIngredient"]); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.Steps, err = steps(n["recipeInstructions"]); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.PrepMinutes, err = minutes(n, "prepTime"); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.CookMinutes, err = minutes(n, "cookTime"); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.TotalMinutes, err = minutes(n, "totalTime"); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.TotalMinutes == nil && r.PrepMinutes != nil && r.CookMinutes != nil {
|
||||
total := *r.PrepMinutes + *r.CookMinutes
|
||||
r.TotalMinutes = &total
|
||||
}
|
||||
if r.Yield, err = yield(n["recipeYield"]); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.JSONLD, err = json.Marshal(n); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// cleanText decodes HTML entities, which recipe plugins often leave in
|
||||
// JSON-LD text, and trims surrounding space.
|
||||
func cleanText(s string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, error) {
|
||||
pubs, ok := publisher.([]any)
|
||||
if !ok {
|
||||
pubs = []any{publisher}
|
||||
}
|
||||
for _, p := range pubs {
|
||||
p, _, err := g.resolve(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("publisher: %w", err)
|
||||
}
|
||||
if m, ok := p.(map[string]any); ok {
|
||||
if name, ok := m["name"].(string); ok && cleanText(name) != "" {
|
||||
return cleanText(name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if ogSiteName != "" {
|
||||
return ogSiteName, nil
|
||||
}
|
||||
return strings.TrimPrefix(base.Hostname(), "www."), nil
|
||||
}
|
||||
|
||||
// imageURL picks the largest of the recipe's images by width × height;
|
||||
// images without both dimensions count as size zero, and the first wins a
|
||||
// tie, so a list of bare URLs gives its first.
|
||||
func imageURL(v any, g graph, base *url.URL) (string, error) {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
if v == nil {
|
||||
return "", nil
|
||||
}
|
||||
items = []any{v}
|
||||
}
|
||||
best, bestArea := "", int64(-1)
|
||||
for _, item := range items {
|
||||
item, found, err := g.resolve(item)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("image: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("image references @id %v, which no node on the page has", item.(map[string]any)["@id"])
|
||||
}
|
||||
var u string
|
||||
var area int64
|
||||
switch img := item.(type) {
|
||||
case string:
|
||||
u = img
|
||||
case map[string]any:
|
||||
if s, ok := img["url"].(string); ok {
|
||||
u = s
|
||||
} else if s, ok := img["contentUrl"].(string); ok {
|
||||
u = s
|
||||
}
|
||||
w, err := dimension(img["width"])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("image width: %w", err)
|
||||
}
|
||||
h, err := dimension(img["height"])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("image height: %w", err)
|
||||
}
|
||||
area = w * h
|
||||
default:
|
||||
return "", fmt.Errorf("image is a %T, not a URL or ImageObject", item)
|
||||
}
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
return "", errors.New("image has no URL")
|
||||
}
|
||||
if area > bestArea {
|
||||
best, bestArea = u, area
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", nil
|
||||
}
|
||||
ref, err := url.Parse(best)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("image URL %q: %w", best, err)
|
||||
}
|
||||
return base.ResolveReference(ref).String(), nil
|
||||
}
|
||||
|
||||
// dimension reads an ImageObject width or height: a number or a string of
|
||||
// digits; zero when absent.
|
||||
func dimension(v any) (int64, error) {
|
||||
switch d := v.(type) {
|
||||
case nil:
|
||||
return 0, nil
|
||||
case json.Number:
|
||||
f, err := d.Float64()
|
||||
if err != nil || f < 0 {
|
||||
return 0, fmt.Errorf("%q is not a size", d)
|
||||
}
|
||||
return int64(f), nil
|
||||
case string:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(d), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, fmt.Errorf("%q is not a size", d)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("a %T is not a size", v)
|
||||
}
|
||||
|
||||
func ingredients(v any) ([]string, error) {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items = []any{v}
|
||||
}
|
||||
var out []string
|
||||
for _, item := range items {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipeIngredient holds a %T, not text", item)
|
||||
}
|
||||
if s = cleanText(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// steps flattens recipeInstructions: one string of lines, or a list of
|
||||
// strings, HowToSteps and HowToSections of those, in order.
|
||||
func steps(v any) ([]string, error) {
|
||||
switch in := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
var out []string
|
||||
for _, line := range strings.Split(in, "\n") {
|
||||
if line = cleanText(line); line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case map[string]any:
|
||||
return stepList([]any{in})
|
||||
case []any:
|
||||
return stepList(in)
|
||||
}
|
||||
return nil, fmt.Errorf("recipeInstructions is a %T", v)
|
||||
}
|
||||
|
||||
func stepList(items []any) ([]string, error) {
|
||||
var out []string
|
||||
for _, item := range items {
|
||||
switch s := item.(type) {
|
||||
case string:
|
||||
if text := cleanText(s); text != "" {
|
||||
out = append(out, text)
|
||||
}
|
||||
case map[string]any:
|
||||
switch {
|
||||
case hasType(s, "HowToStep"):
|
||||
text, _ := s["text"].(string)
|
||||
if text = cleanText(text); text == "" {
|
||||
return nil, errors.New("HowToStep has no text")
|
||||
}
|
||||
out = append(out, text)
|
||||
case hasType(s, "HowToSection"):
|
||||
list, ok := s["itemListElement"].([]any)
|
||||
if !ok {
|
||||
return nil, errors.New("HowToSection has no itemListElement list")
|
||||
}
|
||||
section, err := stepList(list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, section...)
|
||||
default:
|
||||
return nil, fmt.Errorf("recipeInstructions holds a %v, not a HowToStep or HowToSection", s["@type"])
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("recipeInstructions holds a %T", item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// minutes reads a duration field: nil when absent or null; any string
|
||||
// present, empty included, must be an ISO-8601 duration.
|
||||
func minutes(n map[string]any, field string) (*int, error) {
|
||||
v, ok := n[field]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a %T, not an ISO-8601 duration", field, v)
|
||||
}
|
||||
m, err := isoMinutes(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", field, err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// isoMinutes converts an ISO-8601 duration of weeks, days, hours, minutes
|
||||
// and seconds to whole minutes, rounded. Years and months are refused:
|
||||
// their length in minutes is not fixed.
|
||||
func isoMinutes(s string) (int, error) {
|
||||
bad := fmt.Errorf("%q is not an ISO-8601 duration of days, hours, minutes or seconds", s)
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(s), "P")
|
||||
if !ok || rest == "" {
|
||||
return 0, bad
|
||||
}
|
||||
const units = "WDHMS"
|
||||
seconds := map[byte]float64{'W': 7 * 86400, 'D': 86400, 'H': 3600, 'M': 60, 'S': 1}
|
||||
inTime, last, total := false, -1, 0.0
|
||||
for rest != "" {
|
||||
if rest[0] == 'T' {
|
||||
if inTime || len(rest) == 1 {
|
||||
return 0, bad
|
||||
}
|
||||
inTime, rest = true, rest[1:]
|
||||
continue
|
||||
}
|
||||
i := 0
|
||||
for i < len(rest) && (rest[i] >= '0' && rest[i] <= '9' || rest[i] == '.' || rest[i] == ',') {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i == len(rest) {
|
||||
return 0, bad
|
||||
}
|
||||
num, err := strconv.ParseFloat(strings.Replace(rest[:i], ",", ".", 1), 64)
|
||||
if err != nil {
|
||||
return 0, bad
|
||||
}
|
||||
unit := rest[i]
|
||||
pos := strings.IndexByte(units, unit)
|
||||
if pos < 0 || pos <= last || inTime != (pos >= 2) {
|
||||
return 0, bad
|
||||
}
|
||||
last, total, rest = pos, total+num*seconds[unit], rest[i+1:]
|
||||
}
|
||||
if last < 0 {
|
||||
return 0, bad
|
||||
}
|
||||
return int(math.Round(total / 60)), nil
|
||||
}
|
||||
|
||||
func yield(v any) (string, error) {
|
||||
if list, ok := v.([]any); ok {
|
||||
if len(list) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
v = list[0]
|
||||
}
|
||||
switch y := v.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case string:
|
||||
return cleanText(y), nil
|
||||
case json.Number:
|
||||
return y.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("recipeYield is a %T", v)
|
||||
}
|
||||
|
||||
// htmlDoc is what Extract needs from a page's HTML.
|
||||
type htmlDoc struct {
|
||||
jsonLD []string
|
||||
ogSiteName string
|
||||
}
|
||||
|
||||
// scanHTML is a small targeted scan, not a full HTML parser: it walks the
|
||||
// tags, skipping comments and the bodies of scripts and styles, and keeps
|
||||
// the bodies of application/ld+json scripts and the first og:site_name.
|
||||
func scanHTML(doc string) htmlDoc {
|
||||
var out htmlDoc
|
||||
lower := asciiLower(doc)
|
||||
for pos := 0; ; {
|
||||
lt := strings.IndexByte(doc[pos:], '<')
|
||||
if lt < 0 {
|
||||
return out
|
||||
}
|
||||
pos += lt
|
||||
if strings.HasPrefix(doc[pos:], "<!--") {
|
||||
end := strings.Index(doc[pos+4:], "-->")
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
pos += 4 + end + 3
|
||||
continue
|
||||
}
|
||||
name := tagName(lower[pos+1:])
|
||||
if name == "" {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
end := tagEnd(doc, pos+1+len(name))
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
attrs := parseAttrs(doc[pos+1+len(name) : end])
|
||||
pos = end + 1
|
||||
switch name {
|
||||
case "meta":
|
||||
if out.ogSiteName == "" && attrs["property"] == "og:site_name" {
|
||||
out.ogSiteName = strings.TrimSpace(attrs["content"])
|
||||
}
|
||||
case "script", "style":
|
||||
closeAt := strings.Index(lower[pos:], "</"+name)
|
||||
if closeAt < 0 {
|
||||
return out
|
||||
}
|
||||
typ, _, _ := strings.Cut(attrs["type"], ";")
|
||||
if name == "script" && strings.EqualFold(strings.TrimSpace(typ), "application/ld+json") {
|
||||
out.jsonLD = append(out.jsonLD, doc[pos:pos+closeAt])
|
||||
}
|
||||
pos += closeAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// asciiLower lower-cases ASCII letters only, so byte offsets match doc.
|
||||
func asciiLower(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if 'A' <= c && c <= 'Z' {
|
||||
b[i] = c + 'a' - 'A'
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// tagName reads the lower-cased name of an opening tag at the start of s,
|
||||
// or "" when s does not start one.
|
||||
func tagName(s string) string {
|
||||
i := 0
|
||||
for i < len(s) && ('a' <= s[i] && s[i] <= 'z' || i > 0 && '0' <= s[i] && s[i] <= '9') {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i < len(s) && !isSpace(s[i]) && s[i] != '>' && s[i] != '/' {
|
||||
return ""
|
||||
}
|
||||
return s[:i]
|
||||
}
|
||||
|
||||
// tagEnd finds the '>' closing a tag whose attributes start at i, skipping
|
||||
// quoted attribute values.
|
||||
func tagEnd(doc string, i int) int {
|
||||
var quote byte
|
||||
for ; i < len(doc); i++ {
|
||||
switch c := doc[i]; {
|
||||
case quote != 0:
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
quote = c
|
||||
case c == '>':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseAttrs reads a tag's attributes; names are lower-cased, values are
|
||||
// entity-decoded, and the first of a repeated name wins.
|
||||
func parseAttrs(s string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
for i < len(s) && (isSpace(s[i]) || s[i] == '/') {
|
||||
i++
|
||||
}
|
||||
start := i
|
||||
for i < len(s) && !isSpace(s[i]) && s[i] != '=' && s[i] != '/' {
|
||||
i++
|
||||
}
|
||||
if i == start {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
name := asciiLower(s[start:i])
|
||||
for i < len(s) && isSpace(s[i]) {
|
||||
i++
|
||||
}
|
||||
value := ""
|
||||
if i < len(s) && s[i] == '=' {
|
||||
i++
|
||||
for i < len(s) && isSpace(s[i]) {
|
||||
i++
|
||||
}
|
||||
if i < len(s) && (s[i] == '"' || s[i] == '\'') {
|
||||
q := s[i]
|
||||
end := strings.IndexByte(s[i+1:], q)
|
||||
if end < 0 {
|
||||
end = len(s) - i - 1
|
||||
}
|
||||
value = s[i+1 : i+1+end]
|
||||
i += end + 2
|
||||
} else {
|
||||
start := i
|
||||
for i < len(s) && !isSpace(s[i]) {
|
||||
i++
|
||||
}
|
||||
value = s[start:i]
|
||||
}
|
||||
}
|
||||
if _, seen := attrs[name]; !seen {
|
||||
attrs[name] = html.UnescapeString(value)
|
||||
}
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func isSpace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readPage(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func intp(n int) *int { return &n }
|
||||
|
||||
func fmtMinutes(m *int) string {
|
||||
if m == nil {
|
||||
return "nil"
|
||||
}
|
||||
b, _ := json.Marshal(*m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestExtractRealPages(t *testing.T) {
|
||||
tests := []struct {
|
||||
file, url string
|
||||
title, site, image, yld string
|
||||
ingredients, steps int
|
||||
prep, cook, total *int
|
||||
firstStep string
|
||||
}{
|
||||
{
|
||||
// A Recipe script beside VideoObject and BreadcrumbList scripts;
|
||||
// image is an array of one sized ImageObject.
|
||||
file: "bbcgoodfood.html", url: "https://www.bbcgoodfood.com/recipes/easy-chicken-curry",
|
||||
title: "Easy chicken curry", site: "Good Food",
|
||||
image: "https://images.immediate.co.uk/production/volatile/sites/30/2020/08/easy-chicken-curry-aa22a0b.jpg?resize=440,400",
|
||||
ingredients: 11, steps: 4, prep: intp(5), cook: intp(45), total: intp(50), yld: "4",
|
||||
firstStep: "Heat the oil in a flameproof casserole dish",
|
||||
},
|
||||
{
|
||||
// Yoast @graph; image is a list of bare URLs, the first the
|
||||
// full-size one; no publisher, so og:site_name names the site.
|
||||
file: "budgetbytes.html", url: "https://www.budgetbytes.com/one-pot-creamy-cajun-chicken-pasta/",
|
||||
title: "One Pot Creamy Cajun Chicken Pasta", site: "Budget Bytes",
|
||||
image: "https://www.budgetbytes.com/wp-content/uploads/2018/10/One-Pot-Creamy-Cajun-Chicken-Pasta-pan.jpg",
|
||||
ingredients: 17, steps: 6, prep: intp(10), cook: intp(20), total: intp(30), yld: "4",
|
||||
firstStep: "Combine the ingredients for the Cajun seasoning",
|
||||
},
|
||||
{
|
||||
// HowToSection groups (ragu, cheese sauce, assembly), flattened.
|
||||
file: "recipetineats.html", url: "https://www.recipetineats.com/lasagna/",
|
||||
title: "Lasagna", site: "RecipeTin Eats",
|
||||
image: "https://www.recipetineats.com/tachyon/2017/05/Lasagne-recipe-3-main-square.jpg",
|
||||
ingredients: 24, steps: 18, prep: intp(20), cook: intp(240), total: intp(260), yld: "10",
|
||||
firstStep: "Heat oil in a large heavy based pot",
|
||||
},
|
||||
{
|
||||
// @graph of one node; a single ImageObject; instructions as a
|
||||
// list of plain strings; hours and minutes.
|
||||
file: "kingarthur.html", url: "https://www.kingarthurbaking.com/recipes/classic-sandwich-bread-recipe",
|
||||
title: "Classic Sandwich Bread", site: "King Arthur Baking",
|
||||
image: "https://www.kingarthurbaking.com/sites/default/files/2026-02/Classic-Sandwich-Bread-12.jpg",
|
||||
ingredients: 7, steps: 7, prep: intp(12), cook: intp(40), total: intp(162), yld: "16",
|
||||
firstStep: "Weigh your flour",
|
||||
},
|
||||
{
|
||||
// A top-level array; ImageObjects of different sizes.
|
||||
file: "delish.html", url: "https://www.delish.com/cooking/recipe-ideas/a19636089/creamy-tuscan-chicken-recipe/",
|
||||
title: "Creamy Tuscan Chicken", site: "Delish",
|
||||
image: "https://hips.hearstapps.com/hmg-prod/images/1d09a24f-5b76-49f4-9ad6-496acdc03933.jpg?crop=0.502xw:1.00xh;0.250xw,0&resize=1200:*",
|
||||
ingredients: 12, steps: 4, prep: intp(10), cook: intp(0), total: intp(45), yld: "4 serving(s)",
|
||||
firstStep: "In a large skillet over medium heat, heat oil.",
|
||||
},
|
||||
{
|
||||
// A bare Recipe object; image is one URL; HTML entities in steps.
|
||||
file: "foodcom.html", url: "https://www.food.com/recipe/best-banana-bread-2886",
|
||||
title: "Best Banana Bread", site: "Food.com",
|
||||
image: "https://img.sndimg.com/food/image/upload/q_92,fl_progressive,w_1200,c_scale/v1/img/recipes/28/86/wBZxNua1T8yfDwbfo9Fz_0S9A9315.jpg",
|
||||
ingredients: 8, steps: 10, prep: intp(10), cook: intp(60), total: intp(70), yld: "1 loaf, 10 serving(s)",
|
||||
firstStep: "Remove odd pots and pans from oven.",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.file, func(t *testing.T) {
|
||||
r, err := Extract(readPage(t, tt.file), tt.url)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if r.Title != tt.title {
|
||||
t.Errorf("Title = %q, want %q", r.Title, tt.title)
|
||||
}
|
||||
if r.SiteName != tt.site {
|
||||
t.Errorf("SiteName = %q, want %q", r.SiteName, tt.site)
|
||||
}
|
||||
if r.ImageURL != tt.image {
|
||||
t.Errorf("ImageURL = %q, want %q", r.ImageURL, tt.image)
|
||||
}
|
||||
if len(r.Ingredients) != tt.ingredients {
|
||||
t.Errorf("%d ingredients, want %d", len(r.Ingredients), tt.ingredients)
|
||||
}
|
||||
if len(r.Steps) != tt.steps {
|
||||
t.Errorf("%d steps, want %d", len(r.Steps), tt.steps)
|
||||
}
|
||||
if len(r.Steps) > 0 && !strings.HasPrefix(r.Steps[0], tt.firstStep) {
|
||||
t.Errorf("first step = %q, want prefix %q", r.Steps[0], tt.firstStep)
|
||||
}
|
||||
for name, got := range map[string][2]*int{
|
||||
"prep": {r.PrepMinutes, tt.prep}, "cook": {r.CookMinutes, tt.cook}, "total": {r.TotalMinutes, tt.total},
|
||||
} {
|
||||
if fmtMinutes(got[0]) != fmtMinutes(got[1]) {
|
||||
t.Errorf("%s minutes = %s, want %s", name, fmtMinutes(got[0]), fmtMinutes(got[1]))
|
||||
}
|
||||
}
|
||||
if r.Yield != tt.yld {
|
||||
t.Errorf("Yield = %q, want %q", r.Yield, tt.yld)
|
||||
}
|
||||
var node map[string]any
|
||||
if err := json.Unmarshal(r.JSONLD, &node); err != nil || node["name"] == nil {
|
||||
t.Errorf("JSONLD is not the Recipe node: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHowToSectionOrder(t *testing.T) {
|
||||
r, err := Extract(readPage(t, "recipetineats.html"), "https://www.recipetineats.com/lasagna/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Ragu (5 steps), Cheese Sauce (5), Assemble (8): each section's first
|
||||
// step lands right after the previous section's last.
|
||||
for i, prefix := range map[int]string{
|
||||
0: "Heat oil in a large heavy based pot",
|
||||
4: "The ragu is ready when the meat",
|
||||
5: "Warm milk up in a saucepan",
|
||||
10: "Preheat oven to 180°C/350°F.",
|
||||
17: "Stand for 5 to 10 minutes",
|
||||
} {
|
||||
if !strings.HasPrefix(r.Steps[i], prefix) {
|
||||
t.Errorf("step %d = %q, want prefix %q", i+1, r.Steps[i], prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNoRecipe(t *testing.T) {
|
||||
_, err := Extract(readPage(t, "budgetbytes-about.html"), "https://www.budgetbytes.com/about/")
|
||||
if !errors.Is(err, ErrNoRecipe) {
|
||||
t.Fatalf("err = %v, want ErrNoRecipe", err)
|
||||
}
|
||||
_, err = Extract([]byte("<html><head><title>x</title></head></html>"), "https://example.com/")
|
||||
if !errors.Is(err, ErrNoRecipe) {
|
||||
t.Fatalf("page without JSON-LD: err = %v, want ErrNoRecipe", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRefusesNonISODuration(t *testing.T) {
|
||||
// Bon Appétit writes cookTime as "20 minutes".
|
||||
_, err := Extract(readPage(t, "bonappetit.html"), "https://www.bonappetit.com/recipe/bas-best-chocolate-chip-cookies")
|
||||
if !errors.Is(err, ErrMalformedRecipe) || !strings.Contains(err.Error(), "cookTime") {
|
||||
t.Fatalf("err = %v, want ErrMalformedRecipe naming cookTime", err)
|
||||
}
|
||||
}
|
||||
|
||||
// page wraps JSON-LD scripts in a minimal HTML page.
|
||||
func page(head string, jsonLD ...string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html><html><head>" + head)
|
||||
for _, s := range jsonLD {
|
||||
b.WriteString(`<script type="application/ld+json">` + s + "</script>")
|
||||
}
|
||||
b.WriteString("</head><body></body></html>")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func TestExtractShapes(t *testing.T) {
|
||||
const pageURL = "https://www.example.com/recipes/stew/"
|
||||
tests := []struct {
|
||||
name string
|
||||
html []byte
|
||||
check func(t *testing.T, r Recipe)
|
||||
}{
|
||||
{
|
||||
name: "type array and relative image",
|
||||
html: page("", `{"@type":["Recipe","NewsArticle"],"name":"Stew","image":"/img/stew.jpg"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.ImageURL != "https://www.example.com/img/stew.jpg" {
|
||||
t.Errorf("ImageURL = %q", r.ImageURL)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "largest image wins regardless of order",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","image":[
|
||||
"https://cdn.example.com/plain.jpg",
|
||||
{"@type":"ImageObject","url":"small.jpg","width":300,"height":200},
|
||||
{"@type":"ImageObject","contentUrl":"../big.jpg","width":"1600","height":"900"},
|
||||
{"@type":"ImageObject","url":"mid.jpg","width":1200,"height":800}]}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.ImageURL != "https://www.example.com/recipes/big.jpg" {
|
||||
t.Errorf("ImageURL = %q", r.ImageURL)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instructions as one string",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","recipeInstructions":"Brown the beef.\r\n\r\nAdd the stock & simmer.\nServe."}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
want := []string{"Brown the beef.", "Add the stock & simmer.", "Serve."}
|
||||
if strings.Join(r.Steps, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("Steps = %q, want %q", r.Steps, want)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sections and steps flattened in order",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","recipeInstructions":[
|
||||
{"@type":"HowToSection","name":"Beef","itemListElement":[
|
||||
{"@type":"HowToStep","text":"one"},{"@type":"HowToStep","text":"two"}]},
|
||||
{"@type":"HowToStep","text":"three"},
|
||||
{"@type":"HowToSection","name":"Finish","itemListElement":["four"]}]}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if got := strings.Join(r.Steps, " "); got != "one two three four" {
|
||||
t.Errorf("Steps = %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "total derived from prep and cook",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","prepTime":"PT15M","cookTime":"PT1H30M"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if fmtMinutes(r.TotalMinutes) != "105" {
|
||||
t.Errorf("TotalMinutes = %s, want 105", fmtMinutes(r.TotalMinutes))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "total not derived from prep alone",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","prepTime":"PT15M"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.TotalMinutes != nil || r.CookMinutes != nil {
|
||||
t.Errorf("total %s, cook %s; want nil", fmtMinutes(r.TotalMinutes), fmtMinutes(r.CookMinutes))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "site name from host without publisher or og:site_name",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.SiteName != "example.com" {
|
||||
t.Errorf("SiteName = %q", r.SiteName)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "og:site_name with attributes in any order",
|
||||
html: page(`<meta content="Our & Kitchen" property="og:site_name">`, `{"@type":"Recipe","name":"Stew","publisher":{"@id":"#org"}}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.SiteName != "Our & Kitchen" {
|
||||
t.Errorf("SiteName = %q", r.SiteName)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "numeric yield and recipe in @graph beside others",
|
||||
html: page("", `{"@context":"https://schema.org","@graph":[{"@type":"WebPage"},{"@type":"Recipe","name":"Stew","recipeYield":6}]}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.Yield != "6" {
|
||||
t.Errorf("Yield = %q", r.Yield)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image and publisher resolved by @id within @graph",
|
||||
html: page(`<meta property="og:site_name" content="Not this">`, `{"@context":"https://schema.org","@graph":[
|
||||
{"@type":"Recipe","name":"Stew",
|
||||
"image":{"@id":"https://www.example.com/#primaryimage"},
|
||||
"publisher":{"@id":"https://www.example.com/#organization"}},
|
||||
{"@type":"ImageObject","@id":"https://www.example.com/#primaryimage","url":"/img/stew.jpg","width":1200,"height":800},
|
||||
{"@type":"Organization","@id":"https://www.example.com/#organization","name":"Our Kitchen"}]}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.ImageURL != "https://www.example.com/img/stew.jpg" {
|
||||
t.Errorf("ImageURL = %q", r.ImageURL)
|
||||
}
|
||||
if r.SiteName != "Our Kitchen" {
|
||||
t.Errorf("SiteName = %q", r.SiteName)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image @id resolved from another script",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","image":[{"@id":"#img"}]}`,
|
||||
`{"@type":"ImageObject","@id":"#img","contentUrl":"stew.jpg"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.ImageURL != "https://www.example.com/recipes/stew/stew.jpg" {
|
||||
t.Errorf("ImageURL = %q", r.ImageURL)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absolute schema.org type IRIs",
|
||||
html: page("", `{"@type":"https://schema.org/Recipe","name":"Stew","recipeInstructions":[
|
||||
{"@type":"http://schema.org/HowToStep","text":"one"}]}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.Title != "Stew" || strings.Join(r.Steps, "|") != "one" {
|
||||
t.Errorf("Title = %q, Steps = %q", r.Title, r.Steps)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absolute type IRI in a type array",
|
||||
html: page("", `{"@type":["NewsArticle","http://schema.org/Recipe"],"name":"Stew"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.Title != "Stew" {
|
||||
t.Errorf("Title = %q", r.Title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null duration is absent",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew","prepTime":null,"cookTime":"PT10M"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.PrepMinutes != nil || r.TotalMinutes != nil || fmtMinutes(r.CookMinutes) != "10" {
|
||||
t.Errorf("prep %s, cook %s, total %s", fmtMinutes(r.PrepMinutes), fmtMinutes(r.CookMinutes), fmtMinutes(r.TotalMinutes))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "JSON-LD text inside comments and other scripts is ignored",
|
||||
html: []byte(`<html><head><!-- <script type="application/ld+json">{"@type":"Recipe","name":"Old"}</script> -->` +
|
||||
`<script>var s = '<script type="application/ld+json">';</script>` +
|
||||
`<SCRIPT TYPE='application/ld+json; charset=utf-8'>{"@type":"Recipe","name":"Stew"}</SCRIPT></head></html>`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.Title != "Stew" {
|
||||
t.Errorf("Title = %q", r.Title)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, err := Extract(tt.html, pageURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
tt.check(t, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html []byte
|
||||
url string
|
||||
want error
|
||||
}{
|
||||
{"two recipes", page("", `{"@type":"Recipe","name":"A"}`, `{"@type":"Recipe","name":"B"}`), "https://example.com/", ErrManyRecipes},
|
||||
{"invalid JSON", page("", `{"@type":"Recipe",`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"no name", page("", `{"@type":"Recipe"}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"bad step type", page("", `{"@type":"Recipe","name":"A","recipeInstructions":[{"@type":"Thing"}]}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"step without text", page("", `{"@type":"Recipe","name":"A","recipeInstructions":[{"@type":"HowToStep","name":"x"}]}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"bad image width", page("", `{"@type":"Recipe","name":"A","image":{"url":"a.jpg","width":"wide"}}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"empty prepTime", page("", `{"@type":"Recipe","name":"A","prepTime":""}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"blank cookTime", page("", `{"@type":"Recipe","name":"A","cookTime":" "}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"empty totalTime", page("", `{"@type":"Recipe","name":"A","totalTime":""}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"image @id not on page", page("", `{"@type":"Recipe","name":"A","image":{"@id":"#missing"}}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"image @id on two nodes", page("", `{"@type":"Recipe","name":"A","image":{"@id":"#img"}}`,
|
||||
`{"@id":"#img","url":"a.jpg"}`, `{"@id":"#img","url":"b.jpg"}`), "https://example.com/", ErrMalformedRecipe},
|
||||
{"non-text ingredient", page("", `{"@type":"Recipe","name":"A","recipeIngredient":[{"name":"salt"}]}`), "https://example.com/", ErrMalformedRecipe},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Extract(tt.html, tt.url)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("err = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := Extract(page("", `{"@type":"Recipe","name":"A"}`), "/relative"); err == nil {
|
||||
t.Fatal("relative page URL accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestISOMinutes(t *testing.T) {
|
||||
good := map[string]int{
|
||||
"PT0S": 0, "PT45M": 45, "PT1H": 60, "PT2H42M": 162, "PT240M": 240, "P1D": 1440,
|
||||
"P1DT2H": 1560, "PT90S": 2, "PT1.5H": 90, "PT0,5H": 30, "P1W": 10080, " PT5M ": 5,
|
||||
}
|
||||
for in, want := range good {
|
||||
got, err := isoMinutes(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("isoMinutes(%q) = %d, %v; want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "P", "PT", "20 minutes", "PT5", "P1Y", "P1M", "PT1D", "P5H", "PT5M1H", "PT5M5M", "P1DT", "PTxM", "PT1..5M"} {
|
||||
if got, err := isoMinutes(in); err == nil {
|
||||
t.Errorf("isoMinutes(%q) = %d, want error", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
// Package recipes fetches and holds real recipe pages that ground dinners.
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// UserAgent identifies MealQuest to the recipe sites it fetches.
|
||||
UserAgent = "MealQuest/1.0 (household meal planner; recipe fetcher)"
|
||||
// MaxBodyBytes caps a fetched body. A larger page is an error, never
|
||||
// silently truncated: a cut-off recipe page would parse as a wrong one.
|
||||
MaxBodyBytes = 2 << 20
|
||||
// MaxRedirects is how many redirects a fetch follows; each hop is
|
||||
// re-checked for scheme and address.
|
||||
MaxRedirects = 3
|
||||
// Timeout bounds a whole fetch: rate-limit waits, every redirect hop
|
||||
// and reading the body.
|
||||
Timeout = 10 * time.Second
|
||||
// HostInterval is the least time between two requests to one host.
|
||||
HostInterval = time.Second
|
||||
// CacheTTL is how long a fetched page is served from the cache.
|
||||
CacheTTL = time.Hour
|
||||
// CacheSize is how many pages the cache holds; the oldest is evicted
|
||||
// first.
|
||||
CacheSize = 64
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotHTTPS is returned for a URL, or a redirect, that is not
|
||||
// https://.
|
||||
ErrNotHTTPS = errors.New("recipes: only https:// URLs are fetched")
|
||||
// ErrBlockedAddress is returned when a host resolves to an address
|
||||
// that is not public, such as loopback or a private network.
|
||||
ErrBlockedAddress = errors.New("recipes: address is not public")
|
||||
// ErrTooManyRedirects is returned after MaxRedirects redirects.
|
||||
ErrTooManyRedirects = errors.New("recipes: too many redirects")
|
||||
// ErrTooLarge is returned for a body over MaxBodyBytes.
|
||||
ErrTooLarge = errors.New("recipes: body exceeds size cap")
|
||||
// ErrTimeout is returned when a fetch runs past Timeout.
|
||||
ErrTimeout = errors.New("recipes: fetch timed out")
|
||||
)
|
||||
|
||||
// StatusError is returned for a final response whose status is not 2xx.
|
||||
type StatusError struct {
|
||||
URL string
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
return fmt.Sprintf("recipes: GET %s: HTTP %d", e.URL, e.Status)
|
||||
}
|
||||
|
||||
// Page is a fetched page.
|
||||
type Page struct {
|
||||
// URL is the final URL, after redirects.
|
||||
URL string
|
||||
ContentType string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// Resolver looks up a host's addresses; *net.Resolver satisfies it.
|
||||
type Resolver interface {
|
||||
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
|
||||
}
|
||||
|
||||
// dialFunc dials an address that has already passed the public-address
|
||||
// check.
|
||||
type dialFunc func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
// Fetcher retrieves recipe pages without reaching private networks. Its
|
||||
// dialer resolves each host itself, refuses the connection unless every
|
||||
// answer is a public address, and dials the checked address, so a second DNS
|
||||
// answer cannot swap in a private one. Keep-alives are off: every request,
|
||||
// redirect hops included, dials and checks afresh. Proxies from the
|
||||
// environment are not used, since a proxy would dial on the Fetcher's
|
||||
// behalf. Requests are HTTP/1.1, so each connection carries exactly one
|
||||
// request and its first write is that request. A Fetcher is safe for
|
||||
// concurrent use.
|
||||
type Fetcher struct {
|
||||
client *http.Client
|
||||
resolver Resolver
|
||||
dial dialFunc
|
||||
tlsConfig *tls.Config
|
||||
// timeout bounds a fetch; tests shorten it.
|
||||
timeout time.Duration
|
||||
// now is the clock; tests replace it.
|
||||
now func() time.Time
|
||||
// sleep waits d or until ctx is done; tests replace it.
|
||||
sleep func(ctx context.Context, d time.Duration) error
|
||||
|
||||
mu sync.Mutex
|
||||
// lastSent is when each host's latest request began to be written to
|
||||
// its connection; a request whose write fails before any byte is sent
|
||||
// hands its slot back.
|
||||
lastSent map[string]time.Time
|
||||
// released is closed, then replaced, whenever a slot is handed back,
|
||||
// waking requests waiting for their turn.
|
||||
released chan struct{}
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
page Page
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// NewFetcher returns a Fetcher that resolves through the system resolver.
|
||||
func NewFetcher() *Fetcher {
|
||||
return newFetcher(net.DefaultResolver, (&net.Dialer{}).DialContext, nil)
|
||||
}
|
||||
|
||||
// newFetcher returns a Fetcher over resolver and dial; tlsConfig, when not
|
||||
// nil, replaces the default TLS settings so tests can trust their servers.
|
||||
func newFetcher(resolver Resolver, dial dialFunc, tlsConfig *tls.Config) *Fetcher {
|
||||
f := &Fetcher{
|
||||
resolver: resolver,
|
||||
dial: dial,
|
||||
tlsConfig: tlsConfig,
|
||||
timeout: Timeout,
|
||||
now: time.Now,
|
||||
sleep: sleep,
|
||||
lastSent: map[string]time.Time{},
|
||||
released: make(chan struct{}),
|
||||
cache: map[string]cacheEntry{},
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialTLSContext: f.dialTLS,
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
f.client = &http.Client{
|
||||
Transport: &requestTransport{next: transport},
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Fetch returns the page at rawURL, from the cache when it was fetched
|
||||
// within CacheTTL. Only 2xx responses are returned and cached. The
|
||||
// returned Body is the caller's own copy.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (Page, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("recipes: parse URL: %w", err)
|
||||
}
|
||||
if err := checkURL(u); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
key := u.String()
|
||||
if page, ok := f.cached(key); ok {
|
||||
return page, nil
|
||||
}
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
page, err := f.get(fetchCtx, key)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil && errors.Is(fetchCtx.Err(), context.DeadlineExceeded) {
|
||||
return Page{}, fmt.Errorf("%w after %s: %s", ErrTimeout, f.timeout, key)
|
||||
}
|
||||
return Page{}, err
|
||||
}
|
||||
f.store(key, page)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) get(ctx context.Context, rawURL string) (Page, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("recipes: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
final := resp.Request.URL.String()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return Page{}, &StatusError{URL: final, Status: resp.StatusCode}
|
||||
}
|
||||
if resp.ContentLength > MaxBodyBytes {
|
||||
return Page{}, fmt.Errorf("%w: %s declares %d bytes", ErrTooLarge, final, resp.ContentLength)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes+1))
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("recipes: read %s: %w", final, err)
|
||||
}
|
||||
if len(body) > MaxBodyBytes {
|
||||
return Page{}, fmt.Errorf("%w: %s is over %d bytes", ErrTooLarge, final, MaxBodyBytes)
|
||||
}
|
||||
return Page{URL: final, ContentType: resp.Header.Get("Content-Type"), Body: body}, nil
|
||||
}
|
||||
|
||||
// cached returns the page cached under key if it is younger than CacheTTL.
|
||||
func (f *Fetcher) cached(key string) (Page, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e, ok := f.cache[key]
|
||||
if !ok {
|
||||
return Page{}, false
|
||||
}
|
||||
if f.now().Sub(e.fetched) >= CacheTTL {
|
||||
delete(f.cache, key)
|
||||
return Page{}, false
|
||||
}
|
||||
page := e.page
|
||||
page.Body = bytes.Clone(page.Body)
|
||||
return page, true
|
||||
}
|
||||
|
||||
// store caches a copy of page under key, evicting the oldest entry when
|
||||
// full.
|
||||
func (f *Fetcher) store(key string, page Page) {
|
||||
page.Body = bytes.Clone(page.Body)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.cache[key]; !ok && len(f.cache) >= CacheSize {
|
||||
var oldest string
|
||||
var oldestAt time.Time
|
||||
for k, e := range f.cache {
|
||||
if oldest == "" || e.fetched.Before(oldestAt) {
|
||||
oldest, oldestAt = k, e.fetched
|
||||
}
|
||||
}
|
||||
delete(f.cache, oldest)
|
||||
}
|
||||
f.cache[key] = cacheEntry{page: page, fetched: f.now()}
|
||||
}
|
||||
|
||||
// waitTurn blocks until a request to host may be sent, keeping requests to
|
||||
// one host at least HostInterval apart. A wait holds no reservation, and a
|
||||
// request ending with ctx before its turn takes no slot. The request let
|
||||
// through takes the host's slot and must be sent at once, or call release
|
||||
// if sending fails, so that it delays no other request. Fetch calls it from
|
||||
// turnConn's first Write, so the slot is taken after DNS, dialing and the
|
||||
// TLS handshake, as the request is written.
|
||||
func (f *Fetcher) waitTurn(ctx context.Context, host string) (release func(), err error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.mu.Lock()
|
||||
now := f.now()
|
||||
last, sent := f.lastSent[host]
|
||||
wait := last.Add(HostInterval).Sub(now)
|
||||
if !sent || wait <= 0 {
|
||||
f.lastSent[host] = now
|
||||
f.mu.Unlock()
|
||||
return func() { f.release(host, now, last, sent) }, nil
|
||||
}
|
||||
released := f.released
|
||||
f.mu.Unlock()
|
||||
if err := f.waitOrRelease(ctx, wait, released); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// release hands back the slot taken at taken for a request that was not
|
||||
// sent, restoring the host's previous send time, and wakes waiting requests.
|
||||
func (f *Fetcher) release(host string, taken, prev time.Time, hadPrev bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.lastSent[host].Equal(taken) {
|
||||
// A later request already holds the slot.
|
||||
return
|
||||
}
|
||||
if hadPrev {
|
||||
f.lastSent[host] = prev
|
||||
} else {
|
||||
delete(f.lastSent, host)
|
||||
}
|
||||
close(f.released)
|
||||
f.released = make(chan struct{})
|
||||
}
|
||||
|
||||
// waitOrRelease sleeps for d, returning early without error when a slot is
|
||||
// handed back so the caller can look again.
|
||||
func (f *Fetcher) waitOrRelease(ctx context.Context, d time.Duration, released <-chan struct{}) error {
|
||||
sleepCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-released:
|
||||
cancel()
|
||||
case <-sleepCtx.Done():
|
||||
}
|
||||
}()
|
||||
err := f.sleep(sleepCtx, d)
|
||||
select {
|
||||
case <-released:
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// dialPublic resolves address's host once, refuses unless every answer is
|
||||
// public, then dials the checked addresses in turn.
|
||||
func (f *Fetcher) dialPublic(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recipes: dial address %q: %w", address, err)
|
||||
}
|
||||
var addrs []netip.Addr
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
addrs = []netip.Addr{ip}
|
||||
} else {
|
||||
answers, err := f.resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recipes: resolve %s: %w", host, err)
|
||||
}
|
||||
for _, a := range answers {
|
||||
ip, ok := netip.AddrFromSlice(a.IP)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipes: resolve %s: malformed address %v", host, a.IP)
|
||||
}
|
||||
addrs = append(addrs, ip)
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("recipes: resolve %s: no addresses", host)
|
||||
}
|
||||
for _, ip := range addrs {
|
||||
if !IsPublic(ip) {
|
||||
return nil, fmt.Errorf("%w: %s resolves to %s", ErrBlockedAddress, host, ip)
|
||||
}
|
||||
}
|
||||
var errs []error
|
||||
for _, ip := range addrs {
|
||||
conn, err := f.dial(ctx, network, net.JoinHostPort(ip.Unmap().WithZone("").String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// requestCtxKey carries a request's own context into its dial. The
|
||||
// transport dials with a context that keeps the request's values but not
|
||||
// its cancellation; the connection and the rate-limit wait must end with
|
||||
// the request.
|
||||
type requestCtxKey struct{}
|
||||
|
||||
// requestTransport hands each request's context, redirect hops included,
|
||||
// to dialTLS.
|
||||
type requestTransport struct {
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *requestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
ctx := req.Context()
|
||||
return t.next.RoundTrip(req.WithContext(context.WithValue(ctx, requestCtxKey{}, ctx)))
|
||||
}
|
||||
|
||||
// dialTLS connects to address through dialPublic and completes the TLS
|
||||
// handshake, all under the request's context, and returns a turnConn whose
|
||||
// first write waits for the host's rate-limit slot. ALPN offers only
|
||||
// HTTP/1.1, so the connection carries this one request.
|
||||
func (f *Fetcher) dialTLS(dialCtx context.Context, network, address string) (net.Conn, error) {
|
||||
ctx, ok := dialCtx.Value(requestCtxKey{}).(context.Context)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipes: dial %s outside a request", address)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recipes: dial address %q: %w", address, err)
|
||||
}
|
||||
conn, err := f.dialPublic(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &tls.Config{}
|
||||
if f.tlsConfig != nil {
|
||||
cfg = f.tlsConfig.Clone()
|
||||
}
|
||||
cfg.ServerName = host
|
||||
cfg.NextProtos = []string{"http/1.1"}
|
||||
tc := tls.Client(conn, cfg)
|
||||
if err := tc.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("recipes: TLS handshake with %s: %w", host, err)
|
||||
}
|
||||
return &turnConn{Conn: tc, f: f, ctx: ctx, host: strings.ToLower(host)}, nil
|
||||
}
|
||||
|
||||
// turnConn is a connection ready to carry one request. Its first Write —
|
||||
// the start of the request — waits for the host's rate-limit slot, so two
|
||||
// requests to one host are written at least HostInterval apart however
|
||||
// long each spent resolving, dialing and in the TLS handshake. A first
|
||||
// Write that sends nothing hands the slot back.
|
||||
type turnConn struct {
|
||||
net.Conn
|
||||
f *Fetcher
|
||||
ctx context.Context
|
||||
host string
|
||||
// started is set by the first Write; the transport writes a request
|
||||
// from one goroutine, so it needs no lock.
|
||||
started bool
|
||||
}
|
||||
|
||||
func (c *turnConn) Write(p []byte) (int, error) {
|
||||
if c.started {
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
release, err := c.f.waitTurn(c.ctx, c.host)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.started = true
|
||||
n, err := c.Conn.Write(p)
|
||||
if n == 0 && err != nil {
|
||||
release()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// checkURL refuses anything but an absolute https:// URL with a host.
|
||||
func checkURL(u *url.URL) error {
|
||||
if u.Scheme != "https" {
|
||||
return fmt.Errorf("%w: %q", ErrNotHTTPS, u.Redacted())
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return fmt.Errorf("recipes: URL %q has no host", u.Redacted())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRedirect applies checkURL to every hop and stops after
|
||||
// MaxRedirects. The hop's address is checked when it is dialed.
|
||||
func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) > MaxRedirects {
|
||||
return fmt.Errorf("%w: more than %d", ErrTooManyRedirects, MaxRedirects)
|
||||
}
|
||||
return checkURL(req.URL)
|
||||
}
|
||||
|
||||
// blockedIPv4 are the IPv4 special-purpose ranges that are not globally
|
||||
// reachable (IANA IPv4 Special-Purpose Address Registry). IPv4-mapped IPv6
|
||||
// addresses are unmapped before the check, so these cover them too.
|
||||
var blockedIPv4 = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"), // "this network"
|
||||
netip.MustParsePrefix("10.0.0.0/8"), // RFC 1918
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
|
||||
netip.MustParsePrefix("127.0.0.0/8"), // loopback
|
||||
netip.MustParsePrefix("169.254.0.0/16"), // link-local
|
||||
netip.MustParsePrefix("172.16.0.0/12"), // RFC 1918
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
|
||||
netip.MustParsePrefix("192.0.2.0/24"), // documentation, TEST-NET-1
|
||||
netip.MustParsePrefix("192.88.99.0/24"), // 6to4 relay anycast (deprecated)
|
||||
netip.MustParsePrefix("192.168.0.0/16"), // RFC 1918
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // benchmarking
|
||||
netip.MustParsePrefix("198.51.100.0/24"), // documentation, TEST-NET-2
|
||||
netip.MustParsePrefix("203.0.113.0/24"), // documentation, TEST-NET-3
|
||||
netip.MustParsePrefix("224.0.0.0/4"), // multicast
|
||||
netip.MustParsePrefix("240.0.0.0/4"), // reserved, broadcast
|
||||
}
|
||||
|
||||
// globalIPv6 is the only IPv6 range dialed: global unicast. Everything
|
||||
// outside it is refused, including ::, ::1, IPv4-compatible and
|
||||
// IPv4-translated forms, NAT64 (64:ff9b::/96), discard-only (100::/64),
|
||||
// unique local (fc00::/7), link-local (fe80::/10) and multicast (ff00::/8).
|
||||
var globalIPv6 = netip.MustParsePrefix("2000::/3")
|
||||
|
||||
// blockedIPv6 are the special-purpose ranges inside global unicast that are
|
||||
// not public or embed an arbitrary IPv4 address.
|
||||
var blockedIPv6 = []netip.Prefix{
|
||||
netip.MustParsePrefix("2001::/23"), // IETF protocol assignments, Teredo, benchmarking
|
||||
netip.MustParsePrefix("2001:db8::/32"), // documentation
|
||||
netip.MustParsePrefix("2002::/16"), // 6to4, embeds any IPv4
|
||||
netip.MustParsePrefix("3fff::/20"), // documentation
|
||||
}
|
||||
|
||||
// IsPublic reports whether ip is a public unicast address the Fetcher may
|
||||
// dial. IPv4 addresses are public unless in a special-purpose range; IPv6
|
||||
// addresses must be global unicast and outside its special-purpose ranges.
|
||||
func IsPublic(ip netip.Addr) bool {
|
||||
if !ip.IsValid() {
|
||||
return false
|
||||
}
|
||||
ip = ip.Unmap().WithZone("")
|
||||
blocked := blockedIPv4
|
||||
if ip.Is6() {
|
||||
if !globalIPv6.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
blocked = blockedIPv6
|
||||
}
|
||||
for _, p := range blocked {
|
||||
if p.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sleep waits d or until ctx is done, returning ctx's error if it is.
|
||||
func sleep(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubResolver answers each host from a list of answers, one per lookup;
|
||||
// the last answer repeats.
|
||||
type stubResolver struct {
|
||||
mu sync.Mutex
|
||||
answers map[string][][]string
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
func (r *stubResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
seq, ok := r.answers[host]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stub: no such host %s", host)
|
||||
}
|
||||
n := r.calls[host]
|
||||
r.calls[host]++
|
||||
if n >= len(seq) {
|
||||
n = len(seq) - 1
|
||||
}
|
||||
var out []net.IPAddr
|
||||
for _, s := range seq[n] {
|
||||
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *stubResolver) lookups(host string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.calls[host]
|
||||
}
|
||||
|
||||
// testEnv is a Fetcher whose public test addresses all reach one httptest
|
||||
// TLS server, and which records every address it dials.
|
||||
type testEnv struct {
|
||||
f *Fetcher
|
||||
resolver *stubResolver
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
dialed []string
|
||||
}
|
||||
|
||||
// publicIP, publicIP2 and publicIPv6 are public addresses; the test dialer
|
||||
// routes them to the test server and never reaches the real hosts.
|
||||
const (
|
||||
publicIP = "93.184.215.14"
|
||||
publicIP2 = "93.184.215.20"
|
||||
publicIPv6 = "2606:2800:21f::10"
|
||||
)
|
||||
|
||||
func newTestEnv(t *testing.T, handler http.Handler, answers map[string][][]string) *testEnv {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(server.Certificate())
|
||||
env := &testEnv{
|
||||
resolver: &stubResolver{answers: answers, calls: map[string]int{}},
|
||||
server: server,
|
||||
}
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
env.mu.Lock()
|
||||
env.dialed = append(env.dialed, address)
|
||||
env.mu.Unlock()
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if host == publicIP || host == publicIP2 || host == publicIPv6 {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, server.Listener.Addr().String())
|
||||
}
|
||||
return nil, fmt.Errorf("test dial: no route to %s", address)
|
||||
}
|
||||
env.f = newFetcher(env.resolver, dial, &tls.Config{RootCAs: pool})
|
||||
// Real requests in these tests are not rate-limited unless a test
|
||||
// installs a fake clock.
|
||||
env.f.sleep = func(context.Context, time.Duration) error { return nil }
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *testEnv) dials() []string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return append([]string(nil), e.dialed...)
|
||||
}
|
||||
|
||||
func publicAnswers() map[string][][]string {
|
||||
return map[string][][]string{"example.com": {{publicIP}}}
|
||||
}
|
||||
|
||||
func TestFetchReturnsPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotUA string
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, "<html>tacos</html>")
|
||||
}), publicAnswers())
|
||||
|
||||
page, err := env.f.Fetch(t.Context(), "https://example.com/tacos")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if string(page.Body) != "<html>tacos</html>" || page.ContentType != "text/html" || page.URL != "https://example.com/tacos" {
|
||||
t.Errorf("page = %+v", page)
|
||||
}
|
||||
if gotUA != UserAgent {
|
||||
t.Errorf("User-Agent = %q, want %q", gotUA, UserAgent)
|
||||
}
|
||||
if got := env.dials(); len(got) != 1 || got[0] != publicIP+":443" {
|
||||
t.Errorf("dialed %v, want [%s:443]", got, publicIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDialsPublicIPv6(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "ok")
|
||||
}), map[string][][]string{"example.com": {{publicIPv6}}})
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/"); err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if got := env.dials(); len(got) != 1 || got[0] != "["+publicIPv6+"]:443" {
|
||||
t.Errorf("dialed %v, want [[%s]:443]", got, publicIPv6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsNonHTTPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.NotFoundHandler(), publicAnswers())
|
||||
for _, raw := range []string{"http://example.com/tacos", "ftp://example.com/", "example.com/tacos", "https:///tacos"} {
|
||||
_, err := env.f.Fetch(t.Context(), raw)
|
||||
if err == nil {
|
||||
t.Errorf("Fetch(%q) succeeded", raw)
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") && !errors.Is(err, ErrNotHTTPS) {
|
||||
t.Errorf("Fetch(%q) error = %v, want ErrNotHTTPS", raw, err)
|
||||
}
|
||||
}
|
||||
if got := env.dials(); len(got) != 0 {
|
||||
t.Errorf("dialed %v, want nothing", got)
|
||||
}
|
||||
if n := env.resolver.lookups("example.com"); n != 0 {
|
||||
t.Errorf("resolved example.com %d times, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// blockedIPs has an address in every refused range, including IPv4-mapped
|
||||
// forms of the IPv4 ranges.
|
||||
var blockedIPs = []string{
|
||||
"127.0.0.1", "127.255.255.254", "::1", "::ffff:127.0.0.1", // loopback
|
||||
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1", // RFC 1918
|
||||
"::ffff:10.1.2.3", "::ffff:172.20.0.1", "::ffff:192.168.0.10",
|
||||
"169.254.169.254", "::ffff:169.254.169.254", "fe80::1", // link-local
|
||||
"100.64.0.1", "100.127.255.254", "::ffff:100.100.0.1", // carrier-grade NAT
|
||||
"224.0.0.1", "239.255.255.250", "ff02::1", "::ffff:224.0.0.251", // multicast
|
||||
"0.0.0.0", "0.1.2.3", "::", "::ffff:0.0.0.0", // unspecified
|
||||
"fc00::1", "fd12:3456:789a::1", // unique local
|
||||
"255.255.255.255", "240.0.0.1", "fec0::1", // reserved, site-local
|
||||
"192.0.0.1", "192.0.0.255", // IETF protocol assignments
|
||||
"192.0.2.1", "198.51.100.7", "203.0.113.10", "::ffff:203.0.113.10", // documentation
|
||||
"192.88.99.1", // 6to4 relay anycast
|
||||
"198.18.0.1", "198.19.255.255", "::ffff:198.18.0.1", // benchmarking
|
||||
"2001:db8::1", "3fff::1", // IPv6 documentation
|
||||
"2001:2::1", "100::1", // IPv6 benchmarking, discard-only
|
||||
"::ffff:0:a00:1", // IPv4-translated
|
||||
"64:ff9b::a00:1", "64:ff9b:1::a00:1", "2002:a00:1::1", "2001:0:a00:1::1", "::a00:1",
|
||||
}
|
||||
|
||||
func TestIsPublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, s := range blockedIPs {
|
||||
if IsPublic(netip.MustParseAddr(s)) {
|
||||
t.Errorf("IsPublic(%s) = true, want false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"8.8.8.8", publicIP, "::ffff:8.8.8.8", "100.63.255.255", "100.128.0.1", "172.32.0.1",
|
||||
"192.0.1.255", "192.0.3.1", "192.88.98.255", "198.17.255.255", "198.20.0.1",
|
||||
"198.51.99.255", "203.0.112.255", "203.0.114.1", "223.255.255.254",
|
||||
"2606:4700::1111", publicIPv6, "2001:4860:4860::8888", "2a00:1450::1",
|
||||
} {
|
||||
if !IsPublic(netip.MustParseAddr(s)) {
|
||||
t.Errorf("IsPublic(%s) = false, want true", s)
|
||||
}
|
||||
}
|
||||
if IsPublic(netip.Addr{}) {
|
||||
t.Error("IsPublic(zero Addr) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesEachBlockedRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ip := range blockedIPs {
|
||||
t.Run(ip, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.NotFoundHandler(), map[string][][]string{"example.com": {{ip}}})
|
||||
_, err := env.f.Fetch(t.Context(), "https://example.com/")
|
||||
if !errors.Is(err, ErrBlockedAddress) {
|
||||
t.Fatalf("Fetch error = %v, want ErrBlockedAddress", err)
|
||||
}
|
||||
if got := env.dials(); len(got) != 0 {
|
||||
t.Errorf("dialed %v, want nothing", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesBlockedLiteralHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.NotFoundHandler(), nil)
|
||||
for _, raw := range []string{"https://127.0.0.1/", "https://[::1]/", "https://[::ffff:10.0.0.1]/", "https://169.254.169.254/latest/meta-data/", "https://[fe80::1%25eth0]/"} {
|
||||
if _, err := env.f.Fetch(t.Context(), raw); !errors.Is(err, ErrBlockedAddress) {
|
||||
t.Errorf("Fetch(%q) error = %v, want ErrBlockedAddress", raw, err)
|
||||
}
|
||||
}
|
||||
if got := env.dials(); len(got) != 0 {
|
||||
t.Errorf("dialed %v, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesMixedAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.NotFoundHandler(), map[string][][]string{"example.com": {{publicIP, "10.0.0.1"}}})
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/"); !errors.Is(err, ErrBlockedAddress) {
|
||||
t.Fatalf("Fetch error = %v, want ErrBlockedAddress", err)
|
||||
}
|
||||
if got := env.dials(); len(got) != 0 {
|
||||
t.Errorf("dialed %v, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesRedirectToPrivateHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
answers := map[string][][]string{
|
||||
"example.com": {{publicIP}},
|
||||
"evil.example.com": {{"10.0.0.5"}},
|
||||
}
|
||||
for _, target := range []string{"https://evil.example.com/admin", "https://127.0.0.1/admin", "https://[::ffff:192.168.0.1]/"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.RedirectHandler(target, http.StatusFound), answers)
|
||||
_, err := env.f.Fetch(t.Context(), "https://example.com/tacos")
|
||||
if !errors.Is(err, ErrBlockedAddress) {
|
||||
t.Fatalf("Fetch error = %v, want ErrBlockedAddress", err)
|
||||
}
|
||||
if got := env.dials(); len(got) != 1 || got[0] != publicIP+":443" {
|
||||
t.Errorf("dialed %v, want only the first hop", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesRedirectToHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.RedirectHandler("http://example.com/plain", http.StatusMovedPermanently), publicAnswers())
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/"); !errors.Is(err, ErrNotHTTPS) {
|
||||
t.Fatalf("Fetch error = %v, want ErrNotHTTPS", err)
|
||||
}
|
||||
}
|
||||
|
||||
// chainHandler redirects /hop/N to /hop/N-1 until /hop/0, which serves a
|
||||
// page.
|
||||
var chainHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(r.URL.Path, "/hop/%d", &n); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
fmt.Fprint(w, "arrived")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("https://recipes.example.com/hop/%d", n-1), http.StatusFound)
|
||||
})
|
||||
|
||||
func TestFetchFollowsAtMostThreeRedirects(t *testing.T) {
|
||||
t.Parallel()
|
||||
answers := map[string][][]string{
|
||||
"example.com": {{publicIP}},
|
||||
"recipes.example.com": {{publicIP2}},
|
||||
}
|
||||
env := newTestEnv(t, chainHandler, answers)
|
||||
page, err := env.f.Fetch(t.Context(), "https://example.com/hop/3")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch with 3 redirects: %v", err)
|
||||
}
|
||||
if page.URL != "https://recipes.example.com/hop/0" || string(page.Body) != "arrived" {
|
||||
t.Errorf("page = %+v", page)
|
||||
}
|
||||
// Every hop is dialed, so every hop's address is checked.
|
||||
if got := env.dials(); len(got) != 4 || got[0] != publicIP+":443" || got[3] != publicIP2+":443" {
|
||||
t.Errorf("dialed %v, want 4 checked hops", got)
|
||||
}
|
||||
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/hop/4"); !errors.Is(err, ErrTooManyRedirects) {
|
||||
t.Fatalf("Fetch with 4 redirects error = %v, want ErrTooManyRedirects", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchRefusesDNSSwitchToPrivate covers DNS rebinding: the host's first
|
||||
// answer is public and every later one private. The first fetch dials the
|
||||
// address it checked; the next lookup's private answer is refused, and the
|
||||
// private address is never dialed.
|
||||
func TestFetchRefusesDNSSwitchToPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
answers := map[string][][]string{"example.com": {{publicIP}, {"127.0.0.1"}}}
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "ok")
|
||||
}), answers)
|
||||
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/one"); err != nil {
|
||||
t.Fatalf("first Fetch: %v", err)
|
||||
}
|
||||
if n := env.resolver.lookups("example.com"); n != 1 {
|
||||
t.Fatalf("first Fetch resolved %d times, want 1 (checked IP must be dialed IP)", n)
|
||||
}
|
||||
_, err := env.f.Fetch(t.Context(), "https://example.com/two")
|
||||
if !errors.Is(err, ErrBlockedAddress) {
|
||||
t.Fatalf("second Fetch error = %v, want ErrBlockedAddress", err)
|
||||
}
|
||||
if got := env.dials(); len(got) != 1 || got[0] != publicIP+":443" {
|
||||
t.Errorf("dialed %v, want only %s:443", got, publicIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSizeCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
size := MaxBodyBytes
|
||||
if r.URL.Path != "/exact" {
|
||||
size++
|
||||
}
|
||||
if r.URL.Path == "/declared" {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(size))
|
||||
}
|
||||
w.Write([]byte(strings.Repeat("a", size)))
|
||||
}), publicAnswers())
|
||||
|
||||
page, err := env.f.Fetch(t.Context(), "https://example.com/exact")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch at the cap: %v", err)
|
||||
}
|
||||
if len(page.Body) != MaxBodyBytes {
|
||||
t.Errorf("body is %d bytes, want %d", len(page.Body), MaxBodyBytes)
|
||||
}
|
||||
for _, path := range []string{"/declared", "/chunked"} {
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com"+path); !errors.Is(err, ErrTooLarge) {
|
||||
t.Errorf("Fetch %s error = %v, want ErrTooLarge", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/slow-body" {
|
||||
w.Write([]byte("start"))
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
}), publicAnswers())
|
||||
env.f.timeout = 100 * time.Millisecond
|
||||
|
||||
for _, path := range []string{"/slow-headers", "/slow-body"} {
|
||||
start := time.Now()
|
||||
_, err := env.f.Fetch(t.Context(), "https://example.com"+path)
|
||||
if !errors.Is(err, ErrTimeout) {
|
||||
t.Errorf("Fetch %s error = %v, want ErrTimeout", path, err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Errorf("Fetch %s took %s", path, elapsed)
|
||||
}
|
||||
}
|
||||
if Timeout != 10*time.Second {
|
||||
t.Errorf("Timeout = %s, want 10s", Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCallerCancelIsNotTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.NotFoundHandler(), publicAnswers())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
_, err := env.f.Fetch(ctx, "https://example.com/")
|
||||
if !errors.Is(err, context.Canceled) || errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("Fetch error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeClock is a clock whose sleep advances it and records the wait.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
sleeps []time.Duration
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) sleep(_ context.Context, d time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.sleeps = append(c.sleeps, d)
|
||||
c.t = c.t.Add(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
func (c *fakeClock) waits() []time.Duration {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]time.Duration(nil), c.sleeps...)
|
||||
}
|
||||
|
||||
func installClock(f *Fetcher) *fakeClock {
|
||||
c := &fakeClock{t: time.Date(2026, 10, 5, 18, 0, 0, 0, time.UTC)}
|
||||
f.now = c.now
|
||||
f.sleep = c.sleep
|
||||
return c
|
||||
}
|
||||
|
||||
func TestFetchCachesByURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
hits := map[string]int{}
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
hits[r.URL.Path]++
|
||||
n := hits[r.URL.Path]
|
||||
mu.Unlock()
|
||||
if r.URL.Path == "/missing" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s #%d", r.URL.Path, n)
|
||||
}), publicAnswers())
|
||||
clock := installClock(env.f)
|
||||
|
||||
fetch := func(path string) string {
|
||||
t.Helper()
|
||||
page, err := env.f.Fetch(t.Context(), "https://example.com"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch %s: %v", path, err)
|
||||
}
|
||||
return string(page.Body)
|
||||
}
|
||||
if got := fetch("/a"); got != "/a #1" {
|
||||
t.Errorf("first /a = %q", got)
|
||||
}
|
||||
if got := fetch("/a"); got != "/a #1" {
|
||||
t.Errorf("cached /a = %q, want the first response", got)
|
||||
}
|
||||
if got := fetch("/b"); got != "/b #1" {
|
||||
t.Errorf("/b = %q, want its own response", got)
|
||||
}
|
||||
clock.advance(CacheTTL)
|
||||
if got := fetch("/a"); got != "/a #2" {
|
||||
t.Errorf("/a after CacheTTL = %q, want a refetch", got)
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
var se *StatusError
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/missing"); !errors.As(err, &se) || se.Status != http.StatusNotFound {
|
||||
t.Fatalf("Fetch /missing error = %v, want StatusError 404", err)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if hits["/missing"] != 2 {
|
||||
t.Errorf("/missing was requested %d times, want 2 (errors are not cached)", hits["/missing"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheEvictsOldest(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFetcher(&stubResolver{}, nil, nil)
|
||||
clock := installClock(f)
|
||||
for i := range CacheSize + 1 {
|
||||
f.store(fmt.Sprintf("https://example.com/%d", i), Page{})
|
||||
clock.advance(time.Second)
|
||||
}
|
||||
if len(f.cache) != CacheSize {
|
||||
t.Errorf("cache holds %d pages, want %d", len(f.cache), CacheSize)
|
||||
}
|
||||
if _, ok := f.cached("https://example.com/0"); ok {
|
||||
t.Error("oldest page was not evicted")
|
||||
}
|
||||
if _, ok := f.cached(fmt.Sprintf("https://example.com/%d", CacheSize)); !ok {
|
||||
t.Error("newest page is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRateLimitsPerHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
answers := map[string][][]string{
|
||||
"example.com": {{publicIP}},
|
||||
"recipes.example.com": {{publicIP2}},
|
||||
}
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "ok")
|
||||
}), answers)
|
||||
clock := installClock(env.f)
|
||||
|
||||
for _, raw := range []string{"https://example.com/1", "https://recipes.example.com/1", "https://example.com/2", "https://example.com/3"} {
|
||||
if _, err := env.f.Fetch(t.Context(), raw); err != nil {
|
||||
t.Fatalf("Fetch %s: %v", raw, err)
|
||||
}
|
||||
}
|
||||
// example.com/2 waits a full interval; example.com/3 waits another.
|
||||
// recipes.example.com has its own slot and never waits.
|
||||
got := clock.waits()
|
||||
if len(got) != 2 || got[0] != HostInterval || got[1] != HostInterval {
|
||||
t.Errorf("waits = %v, want [%s %s]", got, HostInterval, HostInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCacheIsNotSharedWithCallers(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "tacos")
|
||||
}), publicAnswers())
|
||||
|
||||
for range 2 {
|
||||
page, err := env.f.Fetch(t.Context(), "https://example.com/tacos")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if string(page.Body) != "tacos" {
|
||||
t.Fatalf("body = %q, want the cached page unchanged", page.Body)
|
||||
}
|
||||
// Overwrite the returned body: neither the fetched nor the cached
|
||||
// copy may reach the cache.
|
||||
copy(page.Body, "XXXXX")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchCanceledWaitHoldsNoSlot covers a burst of fetches whose
|
||||
// rate-limit waits end with their context: they must not push back the
|
||||
// next request that is allowed through.
|
||||
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "ok")
|
||||
}), publicAnswers())
|
||||
clock := installClock(env.f)
|
||||
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
|
||||
t.Fatalf("first Fetch: %v", err)
|
||||
}
|
||||
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
|
||||
for i := range 5 {
|
||||
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
|
||||
}
|
||||
}
|
||||
env.f.sleep = clock.sleep
|
||||
clock.advance(HostInterval)
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
|
||||
t.Fatalf("Fetch after the burst: %v", err)
|
||||
}
|
||||
if got := clock.waits(); len(got) != 0 {
|
||||
t.Errorf("waits = %v, want none: canceled waits held slots", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchUnsentRequestHoldsNoSlot covers requests to a host with no
|
||||
// previous request that are never sent — canceled before Fetch, or failing
|
||||
// to connect — and checks that the next request is not delayed.
|
||||
func TestFetchUnsentRequestHoldsNoSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
const unroutableIP = "93.184.215.99"
|
||||
answers := map[string][][]string{"example.com": {{unroutableIP}, {publicIP}}}
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "ok")
|
||||
}), answers)
|
||||
clock := installClock(env.f)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err := env.f.Fetch(ctx, "https://example.com/canceled"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled Fetch error = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/unroutable"); err == nil || !strings.Contains(err.Error(), "no route") {
|
||||
t.Fatalf("unroutable Fetch error = %v, want a dial failure", err)
|
||||
}
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/next"); err != nil {
|
||||
t.Fatalf("next Fetch: %v", err)
|
||||
}
|
||||
if got := clock.waits(); len(got) != 0 {
|
||||
t.Errorf("waits = %v, want none: unsent requests held slots", got)
|
||||
}
|
||||
if got := env.dials(); len(got) != 2 {
|
||||
t.Errorf("dials = %v, want the unroutable and the next request only", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseWakesWaiter checks that a request waiting behind a slot that
|
||||
// is handed back goes ahead at once instead of sleeping out the interval.
|
||||
func TestReleaseWakesWaiter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFetcher(&stubResolver{}, nil, nil)
|
||||
release, err := f.waitTurn(t.Context(), "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("first waitTurn: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() {
|
||||
_, err := f.waitTurn(t.Context(), "example.com")
|
||||
done <- err
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
release()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("waiting waitTurn: %v", err)
|
||||
}
|
||||
if waited := time.Since(start); waited >= HostInterval {
|
||||
t.Errorf("waiter took %s, want less than %s", waited, HostInterval)
|
||||
}
|
||||
case <-time.After(HostInterval / 2):
|
||||
t.Fatal("waiter still waiting after the slot was handed back")
|
||||
}
|
||||
}
|
||||
|
||||
// stallingResolver holds its first lookup until unstall is closed, then
|
||||
// answers from the stub.
|
||||
type stallingResolver struct {
|
||||
*stubResolver
|
||||
once sync.Once
|
||||
stalled chan struct{}
|
||||
unstall chan struct{}
|
||||
}
|
||||
|
||||
func (r *stallingResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
first := false
|
||||
r.once.Do(func() { first = true })
|
||||
if first {
|
||||
close(r.stalled)
|
||||
<-r.unstall
|
||||
}
|
||||
return r.stubResolver.LookupIPAddr(ctx, host)
|
||||
}
|
||||
|
||||
// TestFetchRateLimitsSendsNotSetup covers a request that stalls in DNS
|
||||
// while a later request to the same host is sent: the stalled request must
|
||||
// still be sent a full HostInterval after the other one, because the slot
|
||||
// is taken when the request is written, not when it starts.
|
||||
func TestFetchRateLimitsSendsNotSetup(t *testing.T) {
|
||||
t.Parallel()
|
||||
var clock *fakeClock
|
||||
var mu sync.Mutex
|
||||
sentAt := map[string]time.Time{}
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
sentAt[r.URL.Path] = clock.now()
|
||||
mu.Unlock()
|
||||
fmt.Fprint(w, "ok")
|
||||
}), publicAnswers())
|
||||
resolver := &stallingResolver{
|
||||
stubResolver: env.resolver,
|
||||
stalled: make(chan struct{}),
|
||||
unstall: make(chan struct{}),
|
||||
}
|
||||
env.f.resolver = resolver
|
||||
clock = installClock(env.f)
|
||||
|
||||
slow := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := env.f.Fetch(t.Context(), "https://example.com/slow")
|
||||
slow <- err
|
||||
}()
|
||||
<-resolver.stalled
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/fast"); err != nil {
|
||||
t.Fatalf("fast Fetch: %v", err)
|
||||
}
|
||||
close(resolver.unstall)
|
||||
if err := <-slow; err != nil {
|
||||
t.Fatalf("slow Fetch: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if gap := sentAt["/slow"].Sub(sentAt["/fast"]); gap < HostInterval {
|
||||
t.Errorf("slow request sent %s after fast, want at least %s", gap, HostInterval)
|
||||
}
|
||||
if got := clock.waits(); len(got) != 1 || got[0] != HostInterval {
|
||||
t.Errorf("waits = %v, want [%s]: only the stalled request waits", got, HostInterval)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Trimmed from https://www.bbcgoodfood.com/recipes/easy-chicken-curry (fetched 2026-10-05): head metadata and JSON-LD only. -->
|
||||
<html><head>
|
||||
<title>Easy chicken curry recipe | Good Food</title>
|
||||
<meta property="og:title" content="Easy chicken curry" data-testid="meta-og-title"/>
|
||||
<meta property="og:site_name" content="Good Food" data-testid="meta-og-site-name"/>
|
||||
<meta property="og:url" content="https://www.bbcgoodfood.com/recipes/easy-chicken-curry" data-testid="meta-og-url"/>
|
||||
<meta property="og:image" content="https://images.immediate.co.uk/production/volatile/sites/30/2020/08/easy-chicken-curry-aa22a0b.jpg?resize=440%2C230" data-testid="meta-og-image"/>
|
||||
<script data-testid="schema" type="application/ld+json">{"@context":"https://schema.org","@type":"VideoObject","name":"Easy Chicken Curry","contentUrl":"https://media.delight.video/0c18e53b610964fac175fe0e757adc30557e79b7/cb0e0397ffd102279027b8d892036a85e98afa8b/MEDIA/v0/HD/media.mp4","description":"This easy staple chicken curry is a fantastic recipe for family dinners. It's made with just a handful of ingredients and is enriched with creamy yogurt","embedUrl":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry","thumbnailUrl":"https://media.delight.video/0c18e53b610964fac175fe0e757adc30557e79b7/cb0e0397ffd102279027b8d892036a85e98afa8b/MEDIA/v0/THUMBS/media.0000005.jpg","uploadDate":"2022-07-29T10:34:28.279Z","duration":"PT0M57S","customPosterUrl":"https://media.delight.video/0c18e53b610964fac175fe0e757adc30557e79b7/cb0e0397ffd102279027b8d892036a85e98afa8b/POSTER_USER/v0/cb0e0397ffd102279027b8d892036a85e98afa8b.jpeg"}</script>
|
||||
<script data-testid="page-schema" type="application/ld+json">{"@context":"https://schema.org","@id":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry#Recipe","@type":"Recipe","description":"This easy staple chicken curry is a fantastic recipe for family dinners. It's made with just a handful of ingredients and is enriched with creamy yogurt","image":[{"@type":"ImageObject","url":"https://images.immediate.co.uk/production/volatile/sites/30/2020/08/easy-chicken-curry-aa22a0b.jpg?resize=440,400","width":440,"height":400}],"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry"},"name":"Easy chicken curry","url":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry","author":[{"@type":"Person","name":"Esther Clark","url":"https://www.bbcgoodfood.com/author/estherclark"}],"dateModified":"2026-08-13T14:40:21+01:00","datePublished":"2019-01-25T15:26:05+00:00","headline":"Easy chicken curry","keywords":"1 of 5-a-day, Chicken, Curry, Esther Clark, Family, Family friendly, Gluten free","publisher":{"@type":"Organization","name":"Good Food","url":"https://www.bbcgoodfood.com","logo":{"@type":"ImageObject","height":325,"url":"https://images.immediate.co.uk/production/volatile/sites/30/2026/02/goodfood.logo-f37096c.png?resize=1312,325","width":1312}},"cookTime":"PT45M","nutrition":{"@type":"NutritionInformation","calories":"354 calories","fatContent":"23 grams fat","saturatedFatContent":"4 grams saturated fat","carbohydrateContent":"10 grams carbohydrates","sugarContent":"8 grams sugar","fiberContent":"3 grams fiber","proteinContent":"24 grams protein","sodiumContent":"0.6 milligram of sodium"},"prepTime":"PT5M","recipeCategory":"Dinner, Main course","recipeIngredient":["2 tbsp sunflower oil","1 onion thinly sliced","2 garlic cloves crushed","thumb-sized piece of ginger grated","6 chicken thighs boneless and skinless","3 tbsp medium spice paste (tikka works well)","400g can chopped tomatoes","100g Greek yogurt","1 small bunch of coriander leaves chopped","50g ground almonds","naan breads or cooked basmati rice, to serve"],"recipeInstructions":[{"@type":"HowToStep","text":"Heat the oil in a flameproof casserole dish or large frying pan over a medium heat. Add the onion and a generous pinch of salt and fry for 8-10 mins, or until the onion has turned golden brown and sticky."},{"@type":"HowToStep","text":"Add the garlic and ginger, cooking for a further minute."},{"@type":"HowToStep","text":"Chop the chicken into chunky 3cm pieces, add to the pan and fry for 5 mins before stirring through the spice paste and tomatoes, along with 250ml water. Bring to the boil, lower to a simmer and cook on a gentle heat uncovered for 25-30 mins or until rich and slightly reduced."},{"@type":"HowToStep","text":"Stir though the yogurt, coriander and ground almonds, season and serve with warm naan or fluffy basmati rice."}],"recipeYield":4,"suitableForDiet":"https://schema.org/GlutenFreeDiet","totalTime":"PT50M"}</script>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org/","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://www.bbcgoodfood.com/"},{"@type":"ListItem","position":2,"name":"Recipes","item":"https://www.bbcgoodfood.com/recipes"},{"@type":"ListItem","position":3,"name":"Easy chicken curry"}]}</script>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org/","@id":"https://www.bbcgoodfood.com/recipes/easy-chicken-curry#Recipe","aggregateRating":{"@type":"AggregateRating","ratingValue":5,"reviewCount":363,"bestRating":5,"worstRating":1}}</script>
|
||||
</head><body><main>(page body trimmed)</main></body></html>
|
||||
+10
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Trimmed from https://www.budgetbytes.com/about/ (fetched 2026-10-05): head metadata and JSON-LD only. -->
|
||||
<html><head>
|
||||
<title>The Budget Bytes Story - BudgetBytes.com</title>
|
||||
<meta property="og:title" content="The Budget Bytes Story - BudgetBytes.com" />
|
||||
<meta property="og:url" content="https://www.budgetbytes.com/about/" />
|
||||
<meta property="og:site_name" content="Budget Bytes" />
|
||||
<meta property="og:image" content="https://www.budgetbytes.com/wp-content/uploads/2025/04/Budget-Bytes-Group-Shot_Web.jpg" />
|
||||
<script type="application/ld+json" class="yoast-schema-graph">{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/www.budgetbytes.com\/about\/","url":"https:\/\/www.budgetbytes.com\/about\/","name":"The Budget Bytes Story - BudgetBytes.com","isPartOf":{"@id":"https:\/\/www.budgetbytes.com\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.budgetbytes.com\/about\/#primaryimage"},"image":{"@id":"https:\/\/www.budgetbytes.com\/about\/#primaryimage"},"thumbnailUrl":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2025\/04\/Budget-Bytes-Group-Shot_Web.jpg","datePublished":"2013-05-02T18:19:55+00:00","dateModified":"2026-07-20T15:36:17+00:00","description":"Beth Moncel created Budget Bytes in 2009 to help people learn how to cook and create delicious recipes designed for small budgets.","inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.budgetbytes.com\/about\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.budgetbytes.com\/about\/#primaryimage","url":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2025\/04\/Budget-Bytes-Group-Shot_Web.jpg","contentUrl":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2025\/04\/Budget-Bytes-Group-Shot_Web.jpg","width":1200,"height":800,"caption":"The Budget Bytes team that develops recipes out of our Nashville studio. Left to right: Rebecca Denton (Photographer), Jess Rice (Recipe Development Manager), Jennie Alley (Senior Recipe Developer), and Melissa Nolan (Senior Recipe Developer). We develop, test, and photograph each and every recipe on Budget Bytes from Nashville Tennessee."},{"@type":"WebSite","@id":"https:\/\/www.budgetbytes.com\/#website","url":"https:\/\/www.budgetbytes.com\/","name":"Budget Bytes","description":"Delicious Recipes Designed for Small Budgets","publisher":{"@id":"https:\/\/www.budgetbytes.com\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.budgetbytes.com\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/www.budgetbytes.com\/#organization","name":"Budget Bytes","url":"https:\/\/www.budgetbytes.com\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.budgetbytes.com\/#\/schema\/logo\/image\/","url":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2024\/11\/budgetbytes_square_icon_transparent-300x300-1.png","contentUrl":"https:\/\/www.budgetbytes.com\/wp-content\/uploads\/2024\/11\/budgetbytes_square_icon_transparent-300x300-1.png","width":300,"height":300,"caption":"Budget Bytes"},"image":{"@id":"https:\/\/www.budgetbytes.com\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/budgetbytes1\/?ref=hl","https:\/\/x.com\/Budget_Bytes","https:\/\/instagram.com\/budgetbytes\/","https:\/\/www.pinterest.com\/budgetbytes\/","https:\/\/www.youtube.com\/channel\/UC17vdVOZBVxTSDcldUBBlRg"]}]}</script>
|
||||
</head><body><main>(page body trimmed)</main></body></html>
|
||||
+10
File diff suppressed because one or more lines are too long
+10
File diff suppressed because one or more lines are too long
+9
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Trimmed from https://www.food.com/recipe/best-banana-bread-2886 (fetched 2026-10-05): head metadata and JSON-LD only; the 80 KB review array removed from the Recipe node. -->
|
||||
<html><head>
|
||||
<title>Best Banana Bread Recipe - Food.com</title>
|
||||
<meta name="og:title" content="Best Banana Bread Recipe - Food.com"/>
|
||||
<meta name="og:image" content="https://img.sndimg.com/food/image/upload/w_555,h_416,c_fit,fl_progressive,q_95/v1/img/recipes/28/86/wBZxNua1T8yfDwbfo9Fz_0S9A9315.jpg"/>
|
||||
<meta name="og:url" content=""/>
|
||||
<script type="application/ld+json">{"@context":"http://schema.org","@type":"Recipe","mainEntityOfPage":"true","name":"Best Banana Bread","author":"lkadlec","cookTime":"PT1H","prepTime":"PT10M","totalTime":"PT1H10M","datePublished":"1999-09-26T20:49Z","description":"Make and share this Best Banana Bread recipe from Food.com.","image":"https://img.sndimg.com/food/image/upload/q_92,fl_progressive,w_1200,c_scale/v1/img/recipes/28/86/wBZxNua1T8yfDwbfo9Fz_0S9A9315.jpg","recipeCategory":"Quick Breads","keywords":"Breads,Fruit,Oven,< 4 Hours","recipeIngredient":["1/2 cup butter, softened ","1 cup granulated sugar","2 eggs, beaten ","3 bananas, finely crushed (for serious and extreme moist and delicious, try 4 bananas)","1 1/2 cups all-purpose flour","1 teaspoon baking soda","1/2 teaspoon salt","1/2 teaspoon vanilla (optional)"],"aggregateRating":{"@type":"AggregateRating","ratingValue":"5.0","reviewCount":"2355"},"nutrition":{"@type":"NutritionInformation","calories":"272.8","fatContent":"10.5","saturatedFatContent":"6.2","cholesterolContent":"61.6","sodiumContent":"338.3","carbohydrateContent":"42.5","fiberContent":"1.4","sugarContent":"24.4","proteinContent":"3.7"},"recipeInstructions":[{"@type":"HowToStep","text":"Remove odd pots and pans from oven."},{"@type":"HowToStep","text":"Preheat oven to 350º / 180º."},{"@type":"HowToStep","text":"Cream together butter and sugar."},{"@type":"HowToStep","text":"Add eggs and crushed bananas."},{"@type":"HowToStep","text":"Combine well."},{"@type":"HowToStep","text":"Sift together flour, soda and salt. Add to creamed mixture. Add vanilla."},{"@type":"HowToStep","text":"Mix just until combined. Do not overmix."},{"@type":"HowToStep","text":"Pour into greased and floured loaf pan."},{"@type":"HowToStep","text":"Bake at 350º / 180º for 55 minutes."},{"@type":"HowToStep","text":"Keeps well, refrigerated."}],"recipeYield":"1 loaf, 10 serving(s)","publisher":{"@type":"Organization","name":"Food.com","logo":{"@type":"ImageObject","url":"https://geniuskitchen.sndimg.com/fdc-new/img/FDC-Logo.png"},"url":"https://www.food.com"},"video":[{"@type":"VideoObject","name":"Best Banana Bread","duration":"42S","thumbnailUrl":"https://img.sndimg.com/food/image/upload/w_1024,h_576,c_fit/v1/img/mpx/5458761_16x9.jpg","contentUrl":"https://www.food.com/recipe/best-banana-bread-2886"}]}</script>
|
||||
</head><body><main>(page body trimmed)</main></body></html>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Trimmed from https://www.kingarthurbaking.com/recipes/classic-sandwich-bread-recipe (fetched 2026-10-05): head metadata and JSON-LD only. -->
|
||||
<html><head>
|
||||
<title>Classic Sandwich Bread Recipe | King Arthur Baking</title>
|
||||
<meta property="og:site_name" content="King Arthur Baking" />
|
||||
<meta property="og:url" content="https://www.kingarthurbaking.com/recipes/classic-sandwich-bread-recipe" />
|
||||
<meta property="og:title" content="Classic Sandwich Bread" />
|
||||
<meta property="og:image" content="https://www.kingarthurbaking.com/sites/default/files/2026-02/Classic-Sandwich-Bread-12.jpg" />
|
||||
<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Recipe",
|
||||
"keywords": "Sandwich bread;;Tangzhong",
|
||||
"name": "Classic Sandwich Bread",
|
||||
"description": "Made with all of the ingredients that make homemade white bread superior to storebought — butter, milk, and high-quality flour — this sandwich bread recipe is our version of a classic, perfected. The soft and tender interior makes this loaf ideal for sandwiches, toast, or even French toast. It\u0027s a timeless recipe you\u0027ll come back to time and time again.",
|
||||
"recipeYield": [
|
||||
"16",
|
||||
"1 loaf"
|
||||
],
|
||||
"datePublished": "December 31, 2007 at 7:00pm",
|
||||
"prepTime": "PT12M",
|
||||
"cookTime": "PT40M",
|
||||
"totalTime": "PT2H42M",
|
||||
"recipeInstructions": [
|
||||
"Weigh your flour; or measure it by gently spooning it into a cup, then sweeping off any excess.",
|
||||
"In a large bowl, combine all of the ingredients. Mix and knead everything together — by hand, mixer, or bread machine set on the dough cycle — until you\u0027ve made a smooth dough. If you\u0027re kneading in a stand mixer, it should take 5 to 7 minutes at medium-low speed, and the dough should barely clean the sides of the bowl, perhaps sticking a bit at the bottom. In a bread machine (or by hand), knead the dough for about 6 to 8 minutes, until it forms a smooth ball. ",
|
||||
"Transfer the dough to a lightly greased bowl, cover the bowl, and allow the dough to rise until puffy though not necessarily doubled in bulk, about 1 to 2 hours, depending on the warmth of your kitchen. If you\u0027re using a bread machine, allow the machine to complete its cycle, then leave the dough in the machine until it\u0027s doubled in bulk, perhaps an additional 30 minutes or so.",
|
||||
"Gently deflate the dough and transfer it to a lightly oiled work surface. Shape the dough into an 8\u0022 log.",
|
||||
"Place the log in a lightly greased 8 1/2\u0022 x 4 1/2\u0022 loaf pan, cover the pan loosely with lightly greased plastic wrap, and allow the bread to rise for about 60 minutes, until it\u0027s domed about 1\u0022 above the edge of the pan. A finger pressed into the dough should leave a mark that rebounds slowly. Towards the end of the rise, preheat your oven to 350°F.",
|
||||
"Bake the bread for 30 to 35 minutes, until it\u0027s light golden brown. Test it for doneness by removing it from the pan and thumping it on the bottom (it should sound hollow), or by measuring its interior temperature with a digital thermometer (it should register 190°F at the center of the loaf).",
|
||||
"Remove the bread from the oven, and cool it on a rack before slicing. Store the bread in a plastic bag at room temperature for several days; freeze for longer storage."
|
||||
],
|
||||
"recipeIngredient": [
|
||||
"3 cups (360g) King Arthur Unbleached All-Purpose Flour",
|
||||
"1/2 cup (113g) milk, (skim, 1%, 2% or whole, your choice)*",
|
||||
"1/2 to 2/3 cup (113g to 152g) hot water, enough to make a soft, smooth dough* (see \u0022tips,\u0022 below)",
|
||||
"4 tablespoons (57g) melted butter or 1/4 cup (50g) vegetable oil",
|
||||
"2 tablespoons (25g) granulated sugar",
|
||||
"1 1/4 teaspoons (8g) table salt",
|
||||
"2 teaspoons instant yeast"
|
||||
],
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "King Arthur Baking",
|
||||
"url": "https://www.kingarthurbaking.com/"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "PJ Hamel",
|
||||
"url": "https://www.kingarthurbaking.com/author/pj-hamel"
|
||||
},
|
||||
"image": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://www.kingarthurbaking.com/sites/default/files/2026-02/Classic-Sandwich-Bread-12.jpg"
|
||||
},
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"servingSize": "43g",
|
||||
"calories": "120 calories",
|
||||
"carbohydrateContent": "19g",
|
||||
"cholesterolContent": "10mg",
|
||||
"fiberContent": "1g",
|
||||
"proteinContent": "3g",
|
||||
"sodiumContent": "190mg",
|
||||
"sugarContent": "2g",
|
||||
"fatContent": "3g",
|
||||
"saturatedFatContent": "2g",
|
||||
"transFatContent": "0g"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.60",
|
||||
"ratingCount": "681"
|
||||
}
|
||||
}
|
||||
]
|
||||
}</script>
|
||||
</head><body><main>(page body trimmed)</main></body></html>
|
||||
+11
File diff suppressed because one or more lines are too long
@@ -3,8 +3,6 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5a358c" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "MealQuest",
|
||||
"short_name": "MealQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#5a358c",
|
||||
"background_color": "#f9f6f1",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
-5227
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -5,9 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
|
||||
"build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "npm run lint:colors && vitest run",
|
||||
"lint:colors": "node scripts/lint-colors.mjs",
|
||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||
@@ -29,7 +29,6 @@
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 484 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
@@ -1,186 +0,0 @@
|
||||
// Command generate-icons writes the PWA icons in web/public from the
|
||||
// MealQuest logo. Run it from the repository root:
|
||||
//
|
||||
// go run ./web/scripts/generate-icons
|
||||
//
|
||||
// The logo fills the regular icons edge to edge. The maskable icon shrinks it
|
||||
// onto the colour of its own parchment, so the banner stays inside the
|
||||
// circular safe zone (radius 40% of the icon) that launchers may crop to.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const (
|
||||
source = "design/logos/logo_v1_2Oct2026.jpg"
|
||||
public = "web/public"
|
||||
maskableScale = 0.66
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "generate-icons:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
logo, err := load(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b := logo.Bounds(); b.Dx() != b.Dy() {
|
||||
return fmt.Errorf("%s is %dx%d; the logo must be square", source, b.Dx(), b.Dy())
|
||||
}
|
||||
outputs := []struct {
|
||||
path string
|
||||
image *image.RGBA
|
||||
}{
|
||||
{"icons/icon-192.png", resized(logo, 192)},
|
||||
{"icons/icon-512.png", resized(logo, 512)},
|
||||
{"icons/icon-maskable-512.png", maskable(logo, 512)},
|
||||
{"apple-touch-icon.png", resized(logo, 180)},
|
||||
}
|
||||
for _, out := range outputs {
|
||||
path := filepath.Join(public, out.path)
|
||||
if err := save(path, out.image); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load decodes the logo. The file is a PNG despite its .jpg name, so the
|
||||
// format is sniffed rather than taken from the extension.
|
||||
func load(path string) (image.Image, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func save(path string, img image.Image) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := png.Encoder{CompressionLevel: png.BestCompression}
|
||||
if err := enc.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("encode %s: %w", path, err)
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func resized(logo image.Image, size int) *image.RGBA {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func maskable(logo image.Image, size int) *image.RGBA {
|
||||
// The padding is the median colour of the logo's top edge, which is bare
|
||||
// parchment; a feathered edge hides the seam.
|
||||
b := logo.Bounds()
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.Draw(canvas, canvas.Rect, image.NewUniform(medianColour(logo, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+b.Dy()/40))), image.Point{}, draw.Src)
|
||||
inner := int(math.Round(float64(size) * maskableScale))
|
||||
offset := (size - inner) / 2
|
||||
margin := inner / 24
|
||||
at := image.Rect(offset, offset, offset+inner, offset+inner)
|
||||
draw.DrawMask(canvas, at, resized(logo, inner), image.Point{}, feather(inner, margin), image.Point{}, draw.Over)
|
||||
return canvas
|
||||
}
|
||||
|
||||
// medianColour is the per-channel median of the pixels in r.
|
||||
func medianColour(img image.Image, r image.Rectangle) color.RGBA {
|
||||
var channels [3][]uint8
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
|
||||
channels[0] = append(channels[0], c.R)
|
||||
channels[1] = append(channels[1], c.G)
|
||||
channels[2] = append(channels[2], c.B)
|
||||
}
|
||||
}
|
||||
var median [3]uint8
|
||||
for i, values := range channels {
|
||||
slices.Sort(values)
|
||||
median[i] = values[len(values)/2]
|
||||
}
|
||||
return color.RGBA{R: median[0], G: median[1], B: median[2], A: 0xff}
|
||||
}
|
||||
|
||||
// feather is an opaque square inset by margin on a size×size mask, its edge
|
||||
// softened by a Gaussian blur with a standard deviation of margin/2.
|
||||
func feather(size, margin int) *image.Alpha {
|
||||
values := make([]float64, size*size)
|
||||
for y := margin; y <= size-margin && y < size; y++ {
|
||||
for x := margin; x <= size-margin && x < size; x++ {
|
||||
values[y*size+x] = 255
|
||||
}
|
||||
}
|
||||
kernel := gaussian(float64(margin) / 2)
|
||||
values = blur(values, size, kernel, 1, size)
|
||||
values = blur(values, size, kernel, size, 1)
|
||||
mask := image.NewAlpha(image.Rect(0, 0, size, size))
|
||||
for i, v := range values {
|
||||
mask.Pix[i] = uint8(math.Round(min(max(v, 0), 255)))
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// gaussian is a normalised kernel reaching three standard deviations.
|
||||
func gaussian(sigma float64) []float64 {
|
||||
radius := int(math.Ceil(3 * sigma))
|
||||
kernel := make([]float64, 2*radius+1)
|
||||
var sum float64
|
||||
for i := range kernel {
|
||||
d := float64(i - radius)
|
||||
kernel[i] = math.Exp(-d * d / (2 * sigma * sigma))
|
||||
sum += kernel[i]
|
||||
}
|
||||
for i := range kernel {
|
||||
kernel[i] /= sum
|
||||
}
|
||||
return kernel
|
||||
}
|
||||
|
||||
// blur convolves each line of a size×size grid with kernel: step is the
|
||||
// distance between neighbours along a line and stride between lines. Samples
|
||||
// beyond the edge repeat the edge.
|
||||
func blur(values []float64, size int, kernel []float64, step, stride int) []float64 {
|
||||
radius := len(kernel) / 2
|
||||
out := make([]float64, len(values))
|
||||
for line := range size {
|
||||
for i := range size {
|
||||
var v float64
|
||||
for k, w := range kernel {
|
||||
j := min(max(i+k-radius, 0), size-1)
|
||||
v += w * values[line*stride+j*step]
|
||||
}
|
||||
out[line*stride+i*step] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+8
-103
@@ -1,28 +1,23 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, location = fakeLocation(path)) {
|
||||
return { location, ...renderAppAt(path, location) }
|
||||
}
|
||||
|
||||
function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
return render(
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
|
||||
<App api={createApiClient(location)} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return location
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
@@ -75,7 +70,7 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const { location } = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
||||
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
||||
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
@@ -85,7 +80,7 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('shows the not-in-this-household screen on a 403', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const { location } = renderAt('/')
|
||||
const location = renderAt('/')
|
||||
|
||||
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
|
||||
@@ -104,93 +99,3 @@ describe('App bootstrap', () => {
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App offline bootstrap', () => {
|
||||
let onLine: boolean
|
||||
|
||||
function goOffline() {
|
||||
onLine = false
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
}
|
||||
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
onLine = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/plan', 'Plan'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
renderAt(path)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: title })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||
await visitOnline()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
})
|
||||
|
||||
it('reports the failure offline when this device never verified a session', async () => {
|
||||
goOffline()
|
||||
renderAt('/plan')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports the failure offline after the session was refused', async () => {
|
||||
await visitOnline()
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const { location, unmount } = renderAt('/plan')
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
goOffline()
|
||||
renderAt('/plan')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
onLine = true
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+1
-11
@@ -4,8 +4,6 @@ import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
@@ -42,17 +40,11 @@ export function AppRoutes() {
|
||||
|
||||
export type AppProps = {
|
||||
api: ApiClient
|
||||
// navigator.serviceWorker, or undefined where there is none (and in tests).
|
||||
serviceWorker: UpdateContainer | undefined
|
||||
}
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
export default function App({ api, serviceWorker }: AppProps) {
|
||||
export default function App({ api }: AppProps) {
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
return (
|
||||
<ApiProvider client={api}>
|
||||
@@ -60,8 +52,6 @@ export default function App({ api, serviceWorker }: AppProps) {
|
||||
<AuthBootstrap>
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
<OfflinePill />
|
||||
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ApiError, ME_PATH, useApi } from './api/index.ts'
|
||||
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
|
||||
@@ -11,29 +11,13 @@ export const meQueryKey = [ME_PATH] as const
|
||||
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
|
||||
// already navigating to sign-in, so the skeleton stays up; an account outside
|
||||
// the household sees only the "not in this household" screen.
|
||||
//
|
||||
// When /me cannot reach the server and it last answered with a member on this
|
||||
// device, the saved pages open read-only: the service worker answers their
|
||||
// reads from its cache, and every write is refused for want of a CSRF token.
|
||||
// /me runs even while the browser reports offline, so that case fails fast
|
||||
// instead of waiting, and it is fetched again when the browser is back online.
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
|
||||
// fetch rejects with a TypeError only when the network fails.
|
||||
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = me
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedPages) return
|
||||
return api.network.subscribe(() => {
|
||||
if (!api.network.offline()) void refetch()
|
||||
})
|
||||
}, [savedPages, api, refetch])
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
|
||||
|
||||
if (forbidden) return <NotInHouseholdPage />
|
||||
if (me.isSuccess || savedPages) return children
|
||||
if (me.isSuccess) return children
|
||||
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
|
||||
+13
-83
@@ -1,18 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiError, createApiClient, createVerifiedSession, CSRF_HEADER, VERIFIED_SESSION_KEY } from './index.ts'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
|
||||
@@ -32,7 +29,7 @@ describe('apiFetch', () => {
|
||||
|
||||
it('refuses a non-GET request before /me has loaded', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -50,7 +47,7 @@ describe('apiFetch', () => {
|
||||
'api/v1/me',
|
||||
])('refuses %s without sending it or the CSRF token', async (path) => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
|
||||
@@ -63,7 +60,7 @@ describe('apiFetch', () => {
|
||||
|
||||
it('rejects with a typed ApiError from the error body', async () => {
|
||||
stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
|
||||
@@ -71,14 +68,14 @@ describe('apiFetch', () => {
|
||||
|
||||
it('rejects a response without the API error body', async () => {
|
||||
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
|
||||
})
|
||||
|
||||
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
|
||||
const api = createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(location)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
|
||||
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
||||
@@ -93,7 +90,7 @@ describe('apiFetch', () => {
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
jsonResponse(201, { id: 'j1' }),
|
||||
)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
|
||||
@@ -114,7 +111,7 @@ describe('apiFetch', () => {
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
errorResponse(403, 'csrf'),
|
||||
)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||
@@ -127,7 +124,7 @@ describe('apiFetch', () => {
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const listener = vi.fn()
|
||||
api.subscribe(listener)
|
||||
|
||||
@@ -139,14 +136,14 @@ describe('apiFetch', () => {
|
||||
|
||||
it('does not take a 403 without the API error body as not in this household', async () => {
|
||||
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -155,74 +152,7 @@ describe('apiFetch', () => {
|
||||
describe('loadMe', () => {
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verified session', () => {
|
||||
it('is remembered when /me answers with a member', async () => {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
expect(api.session.verified()).toBe(false)
|
||||
await api.loadMe()
|
||||
expect(api.session.verified()).toBe(true)
|
||||
expect(localStorage.getItem(VERIFIED_SESSION_KEY)).toBe('1')
|
||||
})
|
||||
|
||||
it('stores nothing from the /me response', async () => {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
expect(Object.keys(localStorage)).toEqual([VERIFIED_SESSION_KEY])
|
||||
expect(JSON.stringify({ ...localStorage })).not.toContain(ME.csrfToken)
|
||||
})
|
||||
|
||||
it('is not remembered for a /me response that is not a member', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await expect(api.loadMe()).rejects.toThrow()
|
||||
expect(api.session.verified()).toBe(false)
|
||||
})
|
||||
|
||||
it('is forgotten on a 401', async () => {
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(401, 'unauthenticated'))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 401 })
|
||||
expect(api.session.verified()).toBe(false)
|
||||
})
|
||||
|
||||
it('is forgotten when the account is not in this household', async () => {
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 403 })
|
||||
expect(api.session.verified()).toBe(false)
|
||||
})
|
||||
|
||||
it('is kept when a request fails with a network error', async () => {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
await expect(api.loadMe()).rejects.toThrow(TypeError)
|
||||
expect(api.session.verified()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('network status', () => {
|
||||
it('counts a response the service worker answered from its cache as offline', async () => {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
const cached = jsonResponse(200, { id: '01J9ZR' })
|
||||
cached.headers.set(cacheFallbackHeader, '1')
|
||||
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
|
||||
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
|
||||
expect(api.network.offline()).toBe(true)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
|
||||
expect(api.network.offline()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+8
-44
@@ -1,7 +1,4 @@
|
||||
import type { NetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
||||
import type { VerifiedSession } from './verifiedSession.ts'
|
||||
|
||||
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
|
||||
export const CSRF_HEADER = 'X-MealQuest-CSRF'
|
||||
@@ -39,7 +36,7 @@ export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'ass
|
||||
|
||||
export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON. A GET must not have one.
|
||||
// Sent as JSON.
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
@@ -54,18 +51,11 @@ export type ApiClient = {
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
subscribe(listener: () => void): () => void
|
||||
// network learns of every request that failed with a network error or was
|
||||
// answered from the service worker's cache, and of every request the
|
||||
// server answered.
|
||||
network: NetworkStatus
|
||||
// session remembers, on this device, whether /me last answered with a
|
||||
// member; offline, AuthBootstrap opens the saved pages only if it did.
|
||||
session: VerifiedSession
|
||||
}
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
|
||||
export function createApiClient(location: ApiLocation): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
@@ -80,28 +70,12 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
}
|
||||
headers[CSRF_HEADER] = csrfToken
|
||||
}
|
||||
// Serialize before fetching so a body that cannot be sent is its own error
|
||||
// and never counts as a network failure.
|
||||
let json: string | undefined
|
||||
try {
|
||||
json = body === undefined ? undefined : JSON.stringify(body)
|
||||
} catch (error) {
|
||||
throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error })
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
|
||||
} catch (error) {
|
||||
// fetch rejects with a TypeError when the network fails; an abort is a
|
||||
// DOMException and says nothing about the network.
|
||||
if (error instanceof TypeError) network.requestFailed()
|
||||
throw error
|
||||
}
|
||||
// The service worker marks a response it answered from its cache because
|
||||
// the server could not be reached in time.
|
||||
if (response.headers.has(cacheFallbackHeader)) network.requestFailed()
|
||||
else network.requestSucceeded()
|
||||
return response
|
||||
return fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
@@ -116,11 +90,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
const method = request.method ?? 'GET'
|
||||
const url = apiURL(location.origin, method, path)
|
||||
// fetch refuses a GET with a body by rejecting with a TypeError, the same
|
||||
// error as a network failure, so refuse it here before anything is sent.
|
||||
if (method === 'GET' && request.body !== undefined) {
|
||||
throw new Error(`GET ${path}: a GET request cannot carry a body`)
|
||||
}
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
@@ -144,14 +113,12 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
|
||||
function markForbidden() {
|
||||
if (forbidden) return
|
||||
session.forget()
|
||||
forbidden = true
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
|
||||
if (response.status === 401) {
|
||||
session.forget()
|
||||
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
|
||||
}
|
||||
if (!response.ok) throw await parseApiError(method, path, response)
|
||||
@@ -173,7 +140,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
|
||||
}
|
||||
csrfToken = me.csrfToken
|
||||
session.remember()
|
||||
return me as Me
|
||||
}
|
||||
|
||||
@@ -185,7 +151,5 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
network,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||
export { ApiError } from './errors.ts'
|
||||
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
||||
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
|
||||
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
|
||||
|
||||
@@ -2,8 +2,7 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiProvider, createApiClient, createVerifiedSession, useJob } from './index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
||||
|
||||
type Draft = { dinners: string[] }
|
||||
|
||||
@@ -12,7 +11,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
|
||||
}
|
||||
|
||||
function renderJob(jobId: string | null) {
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// VERIFIED_SESSION_KEY is the localStorage key marking that GET /api/v1/me
|
||||
// last answered with a household member on this device. It holds only "1" —
|
||||
// never the /me response, its CSRF token or anything from /auth.
|
||||
export const VERIFIED_SESSION_KEY = 'mealquest.verified-session'
|
||||
|
||||
export type VerifiedSession = {
|
||||
// verified reports whether /me last answered with a member on this device.
|
||||
verified(): boolean
|
||||
// remember records a /me that answered with a member.
|
||||
remember(): void
|
||||
// forget records that the server refused the session (401) or the account
|
||||
// (not in this household).
|
||||
forget(): void
|
||||
}
|
||||
|
||||
// createVerifiedSession keeps the marker in storage (window.localStorage in
|
||||
// the app). A storage failure is thrown to the caller.
|
||||
export function createVerifiedSession(storage: Storage): VerifiedSession {
|
||||
return {
|
||||
verified: () => storage.getItem(VERIFIED_SESSION_KEY) === '1',
|
||||
remember: () => storage.setItem(VERIFIED_SESSION_KEY, '1'),
|
||||
forget: () => storage.removeItem(VERIFIED_SESSION_KEY),
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -4,20 +4,19 @@ import { BrowserRouter } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
throw new Error('MealQuest: #root element missing from index.html')
|
||||
}
|
||||
|
||||
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
|
||||
const api = createApiClient(window.location)
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
||||
<App api={api} />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// @vitest-environment node
|
||||
// Builds the app with the production config and serves the result, so the
|
||||
// assertions cover what ships in dist: the web manifest, the service worker,
|
||||
// every icon, and the service worker registration that only builds include.
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
|
||||
import tokens from './styles/tokens.css?raw'
|
||||
|
||||
const outDir = 'node_modules/.tmp/pwa-test-dist'
|
||||
|
||||
const icons = [
|
||||
{ path: '/icons/icon-192.png', size: 192 },
|
||||
{ path: '/icons/icon-512.png', size: 512 },
|
||||
{ path: '/icons/icon-maskable-512.png', size: 512 },
|
||||
{ path: '/apple-touch-icon.png', size: 180 },
|
||||
]
|
||||
|
||||
function token(name: string): string {
|
||||
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
|
||||
if (!value) throw new Error(`${name} is not defined in tokens.css`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
|
||||
// signature, the chunk length and the chunk type.
|
||||
// The precache list vite-plugin-pwa injects into sw.js: a JSON array of
|
||||
// { revision, url } entries.
|
||||
function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> {
|
||||
const start = sw.indexOf('[{"revision":')
|
||||
expect(start, 'injected precache list').toBeGreaterThanOrEqual(0)
|
||||
return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2))
|
||||
}
|
||||
|
||||
function pngSize(bytes: Uint8Array): { width: number; height: number } {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
return { width: view.getUint32(16), height: view.getUint32(20) }
|
||||
}
|
||||
|
||||
describe('production build', () => {
|
||||
let server: PreviewServer
|
||||
let origin: string
|
||||
|
||||
async function get(path: string): Promise<Response> {
|
||||
const response = await fetch(new URL(path, origin))
|
||||
expect(response.status, path).toBe(200)
|
||||
return response
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
|
||||
server = await preview({
|
||||
logLevel: 'error',
|
||||
build: { outDir },
|
||||
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
|
||||
})
|
||||
const url = server.resolvedUrls?.local[0]
|
||||
if (!url) throw new Error('preview server did not report a URL')
|
||||
origin = url
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('emits manifest.webmanifest with the MealQuest identity', async () => {
|
||||
const manifest = await (await get('/manifest.webmanifest')).json()
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'MealQuest',
|
||||
short_name: 'MealQuest',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
// --color-accent and --color-bg hold the manifest colours; hex colours
|
||||
// may not be written outside tokens.css.
|
||||
theme_color: token('--color-accent'),
|
||||
background_color: token('--color-bg'),
|
||||
})
|
||||
expect(manifest.icons).toEqual([
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{
|
||||
src: '/icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
|
||||
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
|
||||
expect(pngSize(bytes)).toEqual({ width: size, height: size })
|
||||
})
|
||||
|
||||
it('emits sw.js with the precache list injected', async () => {
|
||||
const sw = await (await get('/sw.js')).text()
|
||||
expect(sw).not.toContain('self.__WB_MANIFEST')
|
||||
expect(sw).toContain('"url":"index.html"')
|
||||
for (const { path } of icons.slice(0, 3)) {
|
||||
expect(sw).toContain(`"url":"${path.slice(1)}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
|
||||
const entries = precacheEntries(await (await get('/sw.js')).text())
|
||||
const urls = entries.map((entry) => entry.url)
|
||||
// index.html keeps its URL across builds, so it carries a content
|
||||
// revision; the other assets have content hashes in their names.
|
||||
expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(urls.some((url) => url.endsWith('.js'))).toBe(true)
|
||||
expect(urls.some((url) => url.endsWith('.css'))).toBe(true)
|
||||
for (const font of [
|
||||
'BagelFatOne-LatinExt',
|
||||
'BagelFatOne-Latin',
|
||||
'Figtree-Regular',
|
||||
'Figtree-SemiBold',
|
||||
'Figtree-Bold',
|
||||
]) {
|
||||
expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
|
||||
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
|
||||
})
|
||||
|
||||
it('registers /sw.js at scope / from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
|
||||
const register = await (await get('/registerSW.js')).text()
|
||||
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev server', () => {
|
||||
let server: ViteDevServer
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('does not register a service worker', async () => {
|
||||
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
|
||||
expect(html).not.toContain('registerSW')
|
||||
expect(html).not.toContain('serviceWorker')
|
||||
expect(html).not.toContain('rel="manifest"')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx'
|
||||
|
||||
const IPHONE_SAFARI =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
|
||||
const IPAD_SAFARI =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
|
||||
const MAC_SAFARI = IPAD_SAFARI
|
||||
const IPHONE_CHROME =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
|
||||
const IPHONE_FIREFOX =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15'
|
||||
const IPHONE_EDGE =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15'
|
||||
const IPHONE_OPERA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0'
|
||||
const IPHONE_MQQBROWSER =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1'
|
||||
const ANDROID_CHROME =
|
||||
'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36'
|
||||
const DESKTOP_CHROME =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
|
||||
|
||||
const copy = 'Tap Share, then Add to Home Screen to get notifications'
|
||||
|
||||
type Device = {
|
||||
userAgent: string
|
||||
maxTouchPoints?: number
|
||||
// display-mode: standalone
|
||||
displayStandalone?: boolean
|
||||
// navigator.standalone (Safari only; undefined elsewhere)
|
||||
navigatorStandalone?: boolean
|
||||
}
|
||||
|
||||
// mockDevice pretends to be a browser with the given user agent and
|
||||
// display mode; jsdom has no matchMedia, navigator.maxTouchPoints or
|
||||
// navigator.standalone.
|
||||
function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints })
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone })
|
||||
vi.stubGlobal('matchMedia', (query: string) => {
|
||||
if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`)
|
||||
return { matches: displayStandalone, media: query } as MediaQueryList
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'maxTouchPoints')
|
||||
Reflect.deleteProperty(navigator, 'standalone')
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('InstallHint', () => {
|
||||
it('shows on iPhone Safari in a browser tab', () => {
|
||||
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||
render(<InstallHint />)
|
||||
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||
})
|
||||
|
||||
it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => {
|
||||
mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||
render(<InstallHint />)
|
||||
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||
})
|
||||
|
||||
it('is hidden when navigator.standalone is true', () => {
|
||||
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true })
|
||||
render(<InstallHint />)
|
||||
expect(screen.queryByRole('note')).toBeNull()
|
||||
})
|
||||
|
||||
it('is hidden when display-mode is standalone', () => {
|
||||
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true })
|
||||
render(<InstallHint />)
|
||||
expect(screen.queryByRole('note')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }],
|
||||
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
|
||||
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
|
||||
['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }],
|
||||
['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }],
|
||||
['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }],
|
||||
['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }],
|
||||
['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }],
|
||||
])('is hidden on %s', (_name, device: Device) => {
|
||||
mockDevice(device)
|
||||
render(<InstallHint />)
|
||||
expect(screen.queryByRole('note')).toBeNull()
|
||||
})
|
||||
|
||||
it('stays dismissed on this device', () => {
|
||||
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||
const first = render(<InstallHint />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.queryByRole('note')).toBeNull()
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
|
||||
|
||||
first.unmount()
|
||||
render(<InstallHint />)
|
||||
expect(screen.queryByRole('note')).toBeNull()
|
||||
})
|
||||
|
||||
it('stays up and reports the error when the dismissal cannot be saved', () => {
|
||||
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError')
|
||||
})
|
||||
|
||||
render(<InstallHint />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded',
|
||||
)
|
||||
|
||||
setItem.mockRestore()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(screen.queryByRole('note')).toBeNull()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
|
||||
})
|
||||
|
||||
it('shows and reports the error when storage cannot be read', () => {
|
||||
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('storage blocked', 'SecurityError')
|
||||
})
|
||||
|
||||
render(<InstallHint />)
|
||||
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked',
|
||||
)
|
||||
|
||||
getItem.mockRestore()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Share, X } from 'lucide-react'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import styles from './InstallHint.module.css'
|
||||
|
||||
// DISMISSED_KEY is the localStorage key remembering, on this device, that the
|
||||
// household dismissed the hint.
|
||||
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
|
||||
|
||||
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
|
||||
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
|
||||
// web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA,
|
||||
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
|
||||
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
|
||||
|
||||
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
|
||||
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
|
||||
// a "Macintosh" with a touch screen is an iPad.
|
||||
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
|
||||
const ua = nav.userAgent
|
||||
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
|
||||
return ios && SAFARI_TAIL.test(ua)
|
||||
}
|
||||
|
||||
// isStandalone reports whether the app is running from the Home Screen.
|
||||
// Safari sets navigator.standalone; other browsers answer the display-mode
|
||||
// media query.
|
||||
export function isStandalone(): boolean {
|
||||
const legacy = (navigator as Navigator & { standalone?: boolean }).standalone
|
||||
return legacy === true || window.matchMedia('(display-mode: standalone)').matches
|
||||
}
|
||||
|
||||
// HintState is whether the household dismissed the hint on this device, and
|
||||
// the storage failure, if any, that stopped us reading or saving that.
|
||||
type HintState = {
|
||||
dismissed: boolean
|
||||
failure: { action: 'read' | 'write'; message: string } | null
|
||||
}
|
||||
|
||||
// Storage can be unavailable (blocked by the browser or privacy settings), in
|
||||
// which case using it throws. The failure is reported, never treated as a
|
||||
// remembered dismissal.
|
||||
function readDismissed(): HintState {
|
||||
try {
|
||||
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
|
||||
} catch (err) {
|
||||
return { dismissed: false, failure: { action: 'read', message: String(err) } }
|
||||
}
|
||||
}
|
||||
|
||||
function writeDismissed(): HintState {
|
||||
try {
|
||||
window.localStorage.setItem(DISMISSED_KEY, '1')
|
||||
return { dismissed: true, failure: null }
|
||||
} catch (err) {
|
||||
return { dismissed: false, failure: { action: 'write', message: String(err) } }
|
||||
}
|
||||
}
|
||||
|
||||
const failureText = {
|
||||
read: 'Could not check whether this hint was dismissed on this device',
|
||||
write: 'Could not remember on this device that this hint was dismissed',
|
||||
}
|
||||
|
||||
// InstallHint tells iOS and iPadOS Safari users how to install the app, which
|
||||
// iOS requires before it delivers Web Push. It shows only in a Safari browser
|
||||
// tab, never once installed, and stays dismissed on this device. If storage
|
||||
// fails, the hint stays up with an error saying so. Used by onboarding step 3
|
||||
// and the Notifications screen.
|
||||
export function InstallHint() {
|
||||
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
|
||||
const [state, setState] = useState<HintState>(() =>
|
||||
eligible ? readDismissed() : { dismissed: false, failure: null },
|
||||
)
|
||||
if (!eligible || state.dismissed) return null
|
||||
|
||||
const { failure } = state
|
||||
return (
|
||||
<>
|
||||
<Card tone="muted" role="note" className={styles.hint}>
|
||||
<Icon icon={Share} className={styles.icon} />
|
||||
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
|
||||
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
|
||||
<Icon icon={X} />
|
||||
</IconButton>
|
||||
</Card>
|
||||
{failure && (
|
||||
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
|
||||
{failureText[failure.action]}: {failure.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.pill {
|
||||
position: fixed;
|
||||
top: calc(var(--space-3) + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
z-index: 20;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-800);
|
||||
color: var(--on-accent);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts'
|
||||
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { createNetworkStatus } from './networkStatus.ts'
|
||||
import { OfflinePill } from './OfflinePill.tsx'
|
||||
|
||||
const copy = 'Offline — showing saved data'
|
||||
|
||||
let onLine: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
onLine = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function goOffline() {
|
||||
onLine = false
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
}
|
||||
|
||||
function goOnline() {
|
||||
onLine = true
|
||||
window.dispatchEvent(new Event('online'))
|
||||
}
|
||||
|
||||
function renderPill(): ApiClient {
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<OfflinePill />
|
||||
</ApiProvider>,
|
||||
)
|
||||
return api
|
||||
}
|
||||
|
||||
describe('OfflinePill', () => {
|
||||
it('is hidden while online', () => {
|
||||
renderPill()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows while the browser is offline when it renders', () => {
|
||||
onLine = false
|
||||
renderPill()
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
})
|
||||
|
||||
it('appears on the offline event and disappears on the online event', () => {
|
||||
renderPill()
|
||||
act(goOffline)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
act(goOnline)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('appears when a request fails with a network error', async () => {
|
||||
const api = renderPill()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
|
||||
act(goOnline)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('disappears when a later request gets a response', async () => {
|
||||
const api = renderPill()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
stubFetch(jsonResponse(200, {}))
|
||||
await act(() => api.apiFetch('/api/v1/plan'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows while online when the service worker answers from its cache', async () => {
|
||||
const api = renderPill()
|
||||
const cached = jsonResponse(200, { id: '01J9ZR' })
|
||||
cached.headers.set(cacheFallbackHeader, '1')
|
||||
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
|
||||
|
||||
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
|
||||
expect(navigator.onLine).toBe(true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
|
||||
// The server answers again.
|
||||
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a request body that cannot be serialized', async () => {
|
||||
const api = renderPill()
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
await act(() => api.loadMe())
|
||||
const body: Record<string, unknown> = {}
|
||||
body.self = body
|
||||
|
||||
await act(() =>
|
||||
expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow(
|
||||
'PUT /api/v1/plan: request body is not serializable as JSON',
|
||||
),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a GET request with a body, which fetch would refuse to send', async () => {
|
||||
const api = renderPill()
|
||||
const fetchMock = stubFetch()
|
||||
|
||||
await act(() =>
|
||||
expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow(
|
||||
'GET /api/v1/plan: a GET request cannot carry a body',
|
||||
),
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an aborted request', async () => {
|
||||
const api = renderPill()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))
|
||||
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes its online and offline listeners on unmount', () => {
|
||||
const add = vi.spyOn(window, 'addEventListener')
|
||||
const remove = vi.spyOn(window, 'removeEventListener')
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const { unmount } = render(
|
||||
<ApiProvider client={api}>
|
||||
<OfflinePill />
|
||||
</ApiProvider>,
|
||||
)
|
||||
const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline')
|
||||
expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online'])
|
||||
|
||||
unmount()
|
||||
for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { WifiOff } from 'lucide-react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import styles from './OfflinePill.module.css'
|
||||
|
||||
// OfflinePill tells the household that what they see came from the cache
|
||||
// while the browser is offline or the server cannot be reached.
|
||||
export function OfflinePill() {
|
||||
const { network } = useApi()
|
||||
const offline = useSyncExternalStore(network.subscribe, network.offline)
|
||||
if (!offline) return null
|
||||
return (
|
||||
<p role="status" className={styles.pill}>
|
||||
<Icon icon={WifiOff} />
|
||||
Offline — showing saved data
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(110px + env(safe-area-inset-bottom));
|
||||
z-index: 20;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2 * var(--space-4));
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SKIP_WAITING, UpdatePrompt, type UpdateContainer } from './UpdatePrompt.tsx'
|
||||
|
||||
// FakeWorker is a ServiceWorker whose state the test moves along.
|
||||
class FakeWorker extends EventTarget {
|
||||
state: ServiceWorkerState = 'installing'
|
||||
postMessage = vi.fn()
|
||||
setState(state: ServiceWorkerState) {
|
||||
this.state = state
|
||||
this.dispatchEvent(new Event('statechange'))
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRegistration extends EventTarget {
|
||||
installing: FakeWorker | null = null
|
||||
waiting: FakeWorker | null = null
|
||||
// install starts a new worker, as the browser does on finding an update.
|
||||
install(): FakeWorker {
|
||||
const worker = new FakeWorker()
|
||||
this.installing = worker
|
||||
this.dispatchEvent(new Event('updatefound'))
|
||||
return worker
|
||||
}
|
||||
// finishInstall moves the installing worker to waiting.
|
||||
finishInstall(worker: FakeWorker) {
|
||||
this.installing = null
|
||||
this.waiting = worker
|
||||
worker.setState('installed')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeContainer extends EventTarget {
|
||||
controller: object | null = {}
|
||||
registration = new FakeRegistration()
|
||||
ready = Promise.resolve(this.registration)
|
||||
// takeOver makes the waiting worker the controller.
|
||||
takeOver() {
|
||||
const worker = this.registration.waiting
|
||||
this.registration.waiting = null
|
||||
this.controller = worker
|
||||
worker?.setState('activated')
|
||||
this.dispatchEvent(new Event('controllerchange'))
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPrompt(container: FakeContainer | undefined) {
|
||||
const reload = vi.fn()
|
||||
const view = render(<UpdatePrompt container={container as unknown as UpdateContainer} reload={reload} />)
|
||||
await act(async () => {
|
||||
await container?.ready
|
||||
})
|
||||
return { reload, ...view }
|
||||
}
|
||||
|
||||
const copy = 'New version ready · Reload'
|
||||
|
||||
describe('UpdatePrompt', () => {
|
||||
it('shows nothing without a service worker', async () => {
|
||||
await renderPrompt(undefined)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows nothing while no worker is waiting', async () => {
|
||||
const container = new FakeContainer()
|
||||
await renderPrompt(container)
|
||||
act(() => {
|
||||
container.registration.install()
|
||||
})
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows nothing for a waiting worker when the page has no controller', async () => {
|
||||
const container = new FakeContainer()
|
||||
container.controller = null
|
||||
container.registration.waiting = new FakeWorker()
|
||||
await renderPrompt(container)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the toast for a worker already waiting when the page loads', async () => {
|
||||
const container = new FakeContainer()
|
||||
container.registration.waiting = new FakeWorker()
|
||||
await renderPrompt(container)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
})
|
||||
|
||||
it('shows the toast when an update finishes installing', async () => {
|
||||
const container = new FakeContainer()
|
||||
await renderPrompt(container)
|
||||
act(() => container.registration.finishInstall(container.registration.install()))
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
})
|
||||
|
||||
it('sends SKIP_WAITING on Reload and reloads once the new worker controls the page', async () => {
|
||||
const container = new FakeContainer()
|
||||
const { reload } = await renderPrompt(container)
|
||||
const worker = container.registration.install()
|
||||
act(() => container.registration.finishInstall(worker))
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'Reload' }).click())
|
||||
expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith(SKIP_WAITING)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
act(() => container.takeOver())
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
// A later controller change (another tab's update) does not reload again.
|
||||
act(() => container.dispatchEvent(new Event('controllerchange')))
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not reappear for the worker the household chose to reload into', async () => {
|
||||
const container = new FakeContainer()
|
||||
container.registration.waiting = new FakeWorker()
|
||||
await renderPrompt(container)
|
||||
act(() => screen.getByRole('button', { name: 'Reload' }).click())
|
||||
// The worker reports another state change before it takes over.
|
||||
act(() => container.registration.waiting?.setState('installed'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides when dismissed until the next update is waiting', async () => {
|
||||
const container = new FakeContainer()
|
||||
const { reload } = await renderPrompt(container)
|
||||
const first = container.registration.install()
|
||||
act(() => container.registration.finishInstall(first))
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'Dismiss' }).click())
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(first.postMessage).not.toHaveBeenCalled()
|
||||
act(() => first.setState('installed'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
|
||||
act(() => container.registration.finishInstall(container.registration.install()))
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides when another tab activates the waiting worker', async () => {
|
||||
const container = new FakeContainer()
|
||||
container.registration.waiting = new FakeWorker()
|
||||
const { reload } = await renderPrompt(container)
|
||||
act(() => container.takeOver())
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes its listeners on unmount', async () => {
|
||||
const container = new FakeContainer()
|
||||
const worker = new FakeWorker()
|
||||
container.registration.waiting = worker
|
||||
const { reload, unmount } = await renderPrompt(container)
|
||||
const removeRegistration = vi.spyOn(container.registration, 'removeEventListener')
|
||||
const removeWorker = vi.spyOn(worker, 'removeEventListener')
|
||||
act(() => screen.getByRole('button', { name: 'Reload' }).click())
|
||||
|
||||
unmount()
|
||||
expect(removeRegistration).toHaveBeenCalledWith('updatefound', expect.any(Function))
|
||||
expect(removeWorker).toHaveBeenCalledWith('statechange', expect.any(Function))
|
||||
container.takeOver()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import styles from './UpdatePrompt.module.css'
|
||||
|
||||
// UpdateContainer is the part of navigator.serviceWorker the prompt uses.
|
||||
export type UpdateContainer = Pick<ServiceWorkerContainer, 'ready' | 'controller' | 'addEventListener' | 'removeEventListener'>
|
||||
|
||||
export type UpdatePromptProps = {
|
||||
// navigator.serviceWorker, or undefined where the browser has none.
|
||||
container: UpdateContainer | undefined
|
||||
// Reloads the page once the new worker controls it.
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
// SKIP_WAITING is the message src/sw.ts answers by activating the waiting
|
||||
// worker.
|
||||
export const SKIP_WAITING = { type: 'SKIP_WAITING' } as const
|
||||
|
||||
// UpdatePrompt offers to reload when a new service worker is installed and
|
||||
// waiting to replace the one controlling the page. Reload asks the waiting
|
||||
// worker to activate and reloads once it controls the page; dismissing hides
|
||||
// the prompt until a different worker is waiting.
|
||||
export function UpdatePrompt({ container, reload }: UpdatePromptProps) {
|
||||
const [waiting, setWaiting] = useState<ServiceWorker | null>(null)
|
||||
const [dismissed, setDismissed] = useState<ServiceWorker | null>(null)
|
||||
const [accepted, setAccepted] = useState<ServiceWorker | null>(null)
|
||||
const reloadListener = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (container === undefined) return
|
||||
const sw = container
|
||||
let registration: ServiceWorkerRegistration | null = null
|
||||
let unmounted = false
|
||||
const watched = new Set<ServiceWorker>()
|
||||
|
||||
// A waiting worker only replaces something when the page has a
|
||||
// controller; without one there is no controllerchange to reload on.
|
||||
function check() {
|
||||
if (registration === null) return
|
||||
setWaiting(sw.controller === null ? null : registration.waiting)
|
||||
}
|
||||
function watch(worker: ServiceWorker | null) {
|
||||
if (worker === null || watched.has(worker)) return
|
||||
watched.add(worker)
|
||||
worker.addEventListener('statechange', check)
|
||||
}
|
||||
function updateFound() {
|
||||
watch(registration?.installing ?? null)
|
||||
}
|
||||
|
||||
void sw.ready.then((ready) => {
|
||||
if (unmounted) return
|
||||
registration = ready
|
||||
ready.addEventListener('updatefound', updateFound)
|
||||
watch(ready.installing)
|
||||
watch(ready.waiting)
|
||||
check()
|
||||
})
|
||||
return () => {
|
||||
unmounted = true
|
||||
registration?.removeEventListener('updatefound', updateFound)
|
||||
watched.forEach((worker) => worker.removeEventListener('statechange', check))
|
||||
if (reloadListener.current !== null) {
|
||||
sw.removeEventListener('controllerchange', reloadListener.current)
|
||||
reloadListener.current = null
|
||||
}
|
||||
}
|
||||
}, [container])
|
||||
|
||||
if (container === undefined || waiting === null || waiting === dismissed || waiting === accepted) return null
|
||||
|
||||
function update() {
|
||||
if (container === undefined || waiting === null || reloadListener.current !== null) return
|
||||
const listener = () => {
|
||||
reloadListener.current = null
|
||||
reload()
|
||||
}
|
||||
reloadListener.current = listener
|
||||
container.addEventListener('controllerchange', listener, { once: true })
|
||||
setAccepted(waiting)
|
||||
waiting.postMessage(SKIP_WAITING)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card role="status" className={styles.toast}>
|
||||
<p className={styles.message}>
|
||||
New version ready <span aria-hidden="true">·</span>
|
||||
</p>
|
||||
{/* The space keeps the copy "New version ready · Reload" in the text;
|
||||
a flex container does not render it. */}{' '}
|
||||
<Button onClick={update}>Reload</Button>
|
||||
<IconButton label="Dismiss" onClick={() => setDismissed(waiting)}>
|
||||
<Icon icon={X} />
|
||||
</IconButton>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// NetworkWindow is the part of window the network status reads: the browser's
|
||||
// online flag and its online/offline events.
|
||||
export type NetworkWindow = Pick<Window, 'navigator' | 'addEventListener' | 'removeEventListener'>
|
||||
|
||||
export type NetworkStatus = {
|
||||
// offline reports whether the browser says it is offline or the last API
|
||||
// request failed with a network error or was answered from the cache.
|
||||
offline(): boolean
|
||||
// subscribe is notified when offline may have changed. It listens for the
|
||||
// window's online/offline events until the returned function is called.
|
||||
subscribe(listener: () => void): () => void
|
||||
// requestFailed records a fetch that rejected with a network error or that
|
||||
// the service worker answered from its cache.
|
||||
requestFailed(): void
|
||||
// requestSucceeded records a fetch the server answered.
|
||||
requestSucceeded(): void
|
||||
}
|
||||
|
||||
// createNetworkStatus tracks whether MealQuest can reach the server. A failed
|
||||
// request counts as offline until a later request gets a response or the
|
||||
// browser reports that it is back online.
|
||||
export function createNetworkStatus(win: NetworkWindow): NetworkStatus {
|
||||
let failed = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function setFailed(value: boolean) {
|
||||
if (failed === value) return
|
||||
failed = value
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
return {
|
||||
offline: () => !win.navigator.onLine || failed,
|
||||
subscribe(listener) {
|
||||
const online = () => {
|
||||
failed = false
|
||||
listener()
|
||||
}
|
||||
listeners.add(listener)
|
||||
win.addEventListener('online', online)
|
||||
win.addEventListener('offline', listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
win.removeEventListener('online', online)
|
||||
win.removeEventListener('offline', listener)
|
||||
}
|
||||
},
|
||||
requestFailed: () => setFailed(true),
|
||||
requestSucceeded: () => setFailed(false),
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
|
||||
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
|
||||
// Every other request goes through the route src/sw/routes.ts decides.
|
||||
// A network-only request is left to the browser: the worker never answers it,
|
||||
// so it reaches the network exactly as the page sent it. That matters for
|
||||
// POST /auth/logout, whose Clear-Site-Data header wipes the worker and its
|
||||
// caches — Chrome stalls that navigation when the worker answers it.
|
||||
// Fonts are precached too, but their route is registered ahead of the
|
||||
// precache route, so a font fetch goes through the expiring CacheFirst
|
||||
// strategy and falls back to the precached copy when the network fails.
|
||||
|
||||
import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import { createApiStrategy } from './sw/apiStrategy'
|
||||
import {
|
||||
cacheNames,
|
||||
cacheVersion,
|
||||
routeFor,
|
||||
staleCaches,
|
||||
type Route,
|
||||
} from './sw/routes'
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
|
||||
}
|
||||
|
||||
// The precache becomes mealquest-precache-v1.
|
||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||
|
||||
const day = 24 * 60 * 60
|
||||
|
||||
const strategies = {
|
||||
[cacheNames.api]: createApiStrategy(),
|
||||
[cacheNames.media]: new CacheFirst({
|
||||
cacheName: cacheNames.media,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }),
|
||||
],
|
||||
}),
|
||||
[cacheNames.fonts]: new CacheFirst({
|
||||
cacheName: cacheNames.fonts,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }),
|
||||
// Offline with no runtime copy yet: the font from the precache.
|
||||
{ handlerDidError: async ({ request }) => matchPrecache(request.url) },
|
||||
],
|
||||
}),
|
||||
}
|
||||
const navigation = new NetworkOnly({
|
||||
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
|
||||
})
|
||||
|
||||
type AnsweredRoute = Exclude<Route, { strategy: 'network-only' }>
|
||||
|
||||
function strategyFor(route: AnsweredRoute): Strategy {
|
||||
switch (route.strategy) {
|
||||
case 'network-first':
|
||||
case 'cache-first':
|
||||
return strategies[route.cacheName]
|
||||
case 'navigation-fallback':
|
||||
return navigation
|
||||
}
|
||||
}
|
||||
|
||||
function routeOf({ request, url }: { request: Request; url: URL }): Route {
|
||||
return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin)
|
||||
}
|
||||
|
||||
// answered matches every request the worker answers: all but network-only.
|
||||
function answered(options: { request: Request; url: URL }): boolean {
|
||||
return routeOf(options).strategy !== 'network-only'
|
||||
}
|
||||
|
||||
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
|
||||
const route = routeOf({ request, url })
|
||||
if (route.strategy === 'network-only') {
|
||||
throw new Error(`the worker never answers network-only ${request.method} ${url.href}`)
|
||||
}
|
||||
return strategyFor(route).handle({ request, event })
|
||||
}
|
||||
|
||||
function isFont(options: { request: Request; url: URL }): boolean {
|
||||
const route = routeOf(options)
|
||||
return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts
|
||||
}
|
||||
|
||||
// Workbox uses the first route that matches, so the font route comes before
|
||||
// the precache route, which would otherwise answer every precached font.
|
||||
registerRoute(isFont, handle, 'GET')
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Workbox routes are per method; every method goes through routeFor, which
|
||||
// sends every non-GET to the network only, so only GETs are ever answered.
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
|
||||
registerRoute(answered, handle, method)
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const current = [workboxCacheNames.precache, ...Object.values(cacheNames)]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))),
|
||||
)
|
||||
})
|
||||
|
||||
// The update prompt (src/pwa/UpdatePrompt.tsx) asks a waiting worker to take
|
||||
// over when the household chooses Reload.
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
// Runs the real Workbox network-first strategy against an in-memory cache
|
||||
// storage: jsdom has none, and the worker itself needs service worker globals.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { createApiStrategy } from './apiStrategy'
|
||||
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
|
||||
let cache: FakeCache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache()
|
||||
// Workbox checks its arguments against these worker-only globals.
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// handle sends a GET through the API strategy, as src/sw.ts does for a
|
||||
// network-first route.
|
||||
async function handle(path: string): Promise<Response> {
|
||||
return answer(new Request(new URL(path, origin)))
|
||||
}
|
||||
|
||||
async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
function recipe(): Response {
|
||||
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('API strategy', () => {
|
||||
it('answers from the network while the server is reachable, unmarked', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
|
||||
const response = await handle('/api/v1/recipes/01J9ZR')
|
||||
expect(response.headers.has(cacheFallbackHeader)).toBe(false)
|
||||
expect(await response.json()).toMatchObject({ id: '01J9ZR' })
|
||||
expect(cache.entries.has(`${origin}/api/v1/recipes/01J9ZR`)).toBe(true)
|
||||
})
|
||||
|
||||
it('opens a previously viewed recipe offline, marked as a cache fallback', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
|
||||
await handle('/api/v1/recipes/01J9ZR')
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
const response = await handle('/api/v1/recipes/01J9ZR')
|
||||
expect(response.headers.get(cacheFallbackHeader)).toBe('1')
|
||||
expect(await response.json()).toEqual({ id: '01J9ZR', name: 'Chicken tikka masala' })
|
||||
})
|
||||
|
||||
it('fails offline for a recipe never viewed', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('previously viewed plan and recipe reads', () => {
|
||||
const plan = { week: '2026-W40', days: [{ date: '2026-10-05', dinner: 'Chicken tikka masala' }] }
|
||||
const recipe = { id: '01J9ZR', name: 'Chicken tikka masala', steps: ['Marinate', 'Simmer'] }
|
||||
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
|
||||
let reachable: boolean
|
||||
|
||||
// The page's fetch goes through the worker's API strategy; the strategy's
|
||||
// own fetch (always a Request) is the network. The API client fetches a
|
||||
// path string, so the two are told apart by the argument's type. As in a
|
||||
// browser, a worker that cannot answer is a network error to the page.
|
||||
beforeEach(() => {
|
||||
reachable = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!(input instanceof Request)) {
|
||||
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
}
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
const body = server[new URL(input.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
|
||||
return jsonResponse(200, body)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function client() {
|
||||
return createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
}
|
||||
|
||||
it('serves the plan and recipe fetched online once the network is gone', async () => {
|
||||
const online = client()
|
||||
expect(await online.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
|
||||
expect(await online.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
|
||||
expect(online.network.offline()).toBe(false)
|
||||
|
||||
reachable = false
|
||||
const offline = client()
|
||||
expect(await offline.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
|
||||
expect(await offline.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
|
||||
expect(offline.network.offline()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the offline indicator when the cached copy answers while the browser reports online', async () => {
|
||||
await client().apiFetch('/api/v1/plans/2026-W40')
|
||||
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
reachable = false
|
||||
const api = client()
|
||||
expect(await api.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
|
||||
expect(api.network.offline()).toBe(true)
|
||||
})
|
||||
|
||||
it('fails offline for a plan never fetched', async () => {
|
||||
reachable = false
|
||||
await expect(client().apiFetch('/api/v1/plans/2026-W41')).rejects.toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
// The network-first strategy for API reads, kept apart from src/sw.ts so the
|
||||
// tests can run it without a service worker.
|
||||
import type { WorkboxPlugin } from 'workbox-core/types'
|
||||
import { NetworkFirst } from 'workbox-strategies'
|
||||
import { cacheNames, markCacheFallback, networkTimeoutSeconds } from './routes'
|
||||
|
||||
// NetworkFirst reads its cache only when the network fails or times out, so
|
||||
// every cached response it uses is a fallback.
|
||||
const markFallback: WorkboxPlugin = {
|
||||
cachedResponseWillBeUsed: async ({ cachedResponse }) =>
|
||||
cachedResponse === undefined ? undefined : markCacheFallback(cachedResponse),
|
||||
}
|
||||
|
||||
export function createApiStrategy(): NetworkFirst {
|
||||
return new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds, plugins: [markFallback] })
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
|
||||
return routeFor({ method, url: new URL(path, origin), mode }, origin)
|
||||
}
|
||||
|
||||
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
describe('routeFor', () => {
|
||||
it.each([
|
||||
'/api/v1/plans',
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners',
|
||||
'/api/v1/dinners/01J9ZK?include=recipe',
|
||||
'/api/v1/recipes',
|
||||
'/api/v1/recipes/01J9ZR',
|
||||
'/api/v1/home',
|
||||
])('GET %s is network-first in the API cache', (path) => {
|
||||
expect(route(path)).toEqual(apiCache)
|
||||
})
|
||||
|
||||
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
|
||||
'GET %s is cache-first in the media cache',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
|
||||
},
|
||||
)
|
||||
|
||||
it('GET of a font file is cache-first in the fonts cache', () => {
|
||||
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
|
||||
strategy: 'cache-first',
|
||||
cacheName: cacheNames.fonts,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/auth/callback?code=x&state=y',
|
||||
'/auth/logout',
|
||||
'/auth/me',
|
||||
'/api/v1/settings',
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/jobs',
|
||||
'/api/v1/jobs/01J9ZK',
|
||||
])('GET %s is network-only', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
|
||||
'a navigation to %s is network-only with no offline fallback',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'/%61uth/login',
|
||||
'/auth%2Fcallback?code=x&state=y',
|
||||
'/%61pi/v1/settings/household',
|
||||
'/api/v1/%73ettings/ai',
|
||||
'/api/v1/settings%2Fai',
|
||||
'/%61pi/v1/jobs/01J9ZK',
|
||||
'/api/v1/j%6Fbs',
|
||||
'/api/v1/plans%2F..%2Fsettings',
|
||||
'/api/v1/recipes%2F..%2Fsettings%2Fai',
|
||||
'/api/v1/recipes/..%2F..%2F..%2Fauth%2Flogin',
|
||||
'/plan%2F..%2Fauth%2Flogin',
|
||||
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
|
||||
'a malformed encoding in %s is network-only, navigated or not',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it('classifies other API paths by their decoded form', () => {
|
||||
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/recipes/01J9ZR')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
|
||||
'%s is network-only on every URL class',
|
||||
(method) => {
|
||||
for (const path of [
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners/01J9ZK',
|
||||
'/api/v1/recipes/01J9ZR',
|
||||
'/api/v1/home',
|
||||
'/media/photos/abc.jpg',
|
||||
'/assets/Figtree-Bold-a1b2c3.woff2',
|
||||
'/auth/logout',
|
||||
'/plan',
|
||||
]) {
|
||||
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
|
||||
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('matches whole path segments only', () => {
|
||||
expect(route('/api/v1/plansx')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/homepage')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/recipesx')).toEqual(networkOnly)
|
||||
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
|
||||
expect(route('/authors')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves other API calls network-only', () => {
|
||||
expect(route('/api/v1/me')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves cross-origin requests network-only', () => {
|
||||
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
|
||||
const url = new URL(path, 'https://cdn.example')
|
||||
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
|
||||
'a navigation to the app at %s falls back to the precached index.html',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves other same-origin GETs network-only', () => {
|
||||
expect(route('/health')).toEqual(networkOnly)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
it('deletes only MealQuest caches that are not current', () => {
|
||||
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
|
||||
const names = [
|
||||
...current,
|
||||
'mealquest-precache',
|
||||
'mealquest-api-v0',
|
||||
'workbox-precache-v2-https://mealquest.test/',
|
||||
'other-app-cache',
|
||||
'my-mealquest-cache',
|
||||
]
|
||||
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('markCacheFallback', () => {
|
||||
it('copies the cached response with the cache fallback header set', async () => {
|
||||
const cached = new Response('{"id":"01J9ZR"}', {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const marked = markCacheFallback(cached)
|
||||
expect(marked.status).toBe(200)
|
||||
expect(marked.statusText).toBe('OK')
|
||||
expect(marked.headers.get('Content-Type')).toBe('application/json')
|
||||
expect(marked.headers.get(cacheFallbackHeader)).toBe('1')
|
||||
expect(await marked.json()).toEqual({ id: '01J9ZR' })
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
// Decides how the service worker handles each request. This module is pure —
|
||||
// no Workbox, no service worker globals — so the tests can assert every URL
|
||||
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
|
||||
//
|
||||
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
|
||||
// non-GET request are network-only and never reach a cache. Paths are matched
|
||||
// as the server sees them — percent-decoded, before and after dot segments are
|
||||
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
|
||||
// network-only.
|
||||
|
||||
export type Route =
|
||||
// Network with a 3 s timeout, then the last cached response.
|
||||
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
|
||||
// Cached response when there is one, else the network; entries expire.
|
||||
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
|
||||
// Network only, nothing cached: the worker leaves the request to the browser.
|
||||
| { strategy: 'network-only' }
|
||||
// Network only, nothing cached; offline, the precached index.html.
|
||||
| { strategy: 'navigation-fallback' }
|
||||
|
||||
export interface RouteRequest {
|
||||
method: string
|
||||
url: URL
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
// cacheFallbackHeader marks a network-first response answered from the cache
|
||||
// because the server could not be reached in time, so the app can show the
|
||||
// offline indicator even while the browser reports that it is online.
|
||||
export const cacheFallbackHeader = 'X-MealQuest-Cache-Fallback'
|
||||
|
||||
// markCacheFallback copies a cached response with cacheFallbackHeader set.
|
||||
export function markCacheFallback(cached: Response): Response {
|
||||
const headers = new Headers(cached.headers)
|
||||
headers.set(cacheFallbackHeader, '1')
|
||||
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers })
|
||||
}
|
||||
|
||||
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/recipes', '/api/v1/home']
|
||||
|
||||
// True when path is prefix itself or a path below it: /api/v1/plans matches
|
||||
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
|
||||
function under(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
// The pathname percent-decoded, as the server's router sees it, or undefined
|
||||
// when the encoding is malformed.
|
||||
function decodePath(pathname: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves "." and ".." segments the way the server's path.Clean does, so a
|
||||
// decoded %2F cannot carry a request out of a reserved prefix:
|
||||
// /api/v1/plans/../settings cleans to /api/v1/settings.
|
||||
function cleanPath(path: string): string {
|
||||
const segments: string[] = []
|
||||
for (const segment of path.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') segments.pop()
|
||||
else segments.push(segment)
|
||||
}
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
export function routeFor(request: RouteRequest, origin: string): Route {
|
||||
const { method, url, mode } = request
|
||||
if (method !== 'GET') return networkOnly
|
||||
if (url.origin !== origin) return networkOnly
|
||||
const path = decodePath(url.pathname)
|
||||
if (path === undefined) return networkOnly
|
||||
const cleaned = cleanPath(path)
|
||||
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
|
||||
if (neverCached.some(reserved)) return networkOnly
|
||||
if (networkFirst.some((prefix) => under(path, prefix))) {
|
||||
return { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
}
|
||||
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
|
||||
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
|
||||
if (reserved('/api')) return networkOnly
|
||||
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
|
||||
return networkOnly
|
||||
}
|
||||
|
||||
// The caches activate deletes: MealQuest caches that are no longer current.
|
||||
// Caches without the mealquest- prefix belong to someone else and are kept.
|
||||
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
|
||||
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// The service worker itself needs worker globals, so these tests read its
|
||||
// source: they pin the parts routes.ts cannot express — the API cache's
|
||||
// strategy, the font strategy's expiration, the font route's place ahead of
|
||||
// the precache route and network-only requests left to the browser.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
describe('service worker source', () => {
|
||||
it('serves the API cache with the strategy that marks cache fallbacks', () => {
|
||||
expect(source).toContain('[cacheNames.api]: createApiStrategy(),')
|
||||
})
|
||||
|
||||
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
|
||||
expect(source).toMatch(
|
||||
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the precached font when the network fails', () => {
|
||||
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
|
||||
})
|
||||
|
||||
it('registers the font route before the precache route', () => {
|
||||
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
|
||||
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
|
||||
expect(fontRoute).toBeGreaterThanOrEqual(0)
|
||||
expect(precache).toBeGreaterThan(fontRoute)
|
||||
})
|
||||
|
||||
it('leaves network-only requests to the browser instead of answering them', () => {
|
||||
// Chrome stalls a navigation whose Clear-Site-Data response (POST
|
||||
// /auth/logout) the worker answered, so the worker must not match it.
|
||||
expect(source).toMatch(
|
||||
/function answered\(options: \{ request: Request; url: URL \}\): boolean \{\s*return routeOf\(options\)\.strategy !== 'network-only'/,
|
||||
)
|
||||
expect(source).toContain('registerRoute(answered, handle, method)')
|
||||
expect(source).not.toContain('registerRoute(() => true')
|
||||
})
|
||||
|
||||
it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => {
|
||||
expect(source).toMatch(
|
||||
/self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,4 @@ import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
// The verified-session marker and other device state must not leak between
|
||||
// tests. Tests in the node environment (src/pwa.test.ts) have no storage.
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear()
|
||||
})
|
||||
|
||||
+1
-3
@@ -13,11 +13,9 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "manifest.json"],
|
||||
"exclude": ["src/sw.ts"]
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/sw.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
+1
-21
@@ -1,30 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa'
|
||||
import manifest from './manifest.json' with { type: 'json' }
|
||||
|
||||
const backend = 'http://localhost:8080'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
// The plugin writes manifest.webmanifest and sw.js, links the manifest
|
||||
// from index.html and adds the registerSW.js script. It does both only
|
||||
// in production builds: dev options stay off, so neither `vite` nor
|
||||
// Vitest registers a service worker.
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
injectRegister: 'script',
|
||||
// Precache the shell, JS, CSS and the self-hosted fonts.
|
||||
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] },
|
||||
// The web manifest lives in manifest.json: its theme and background
|
||||
// colours are --color-accent and --color-bg from src/styles/tokens.css,
|
||||
// and hex colours may not be written in TypeScript.
|
||||
manifest: manifest as Partial<ManifestOptions>,
|
||||
}),
|
||||
],
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
|
||||
Reference in New Issue
Block a user