release: repair the catalog 404 and stop the page jumping #3

Merged
Kessinen merged 6 commits from dev into main 2026-09-05 21:03:04 +00:00
9 changed files with 124 additions and 51 deletions
Showing only changes of commit c6a8532569 - Show all commits
+14 -7
View File
@@ -1,22 +1,29 @@
# Copy to .env and fill in. .env is gitignored — the real registry hostname # Copy to .env and fill in. .env is gitignored — the real registry hostname
# must not end up in the repository. # must not end up in the repository.
# Image coordinates. FOODSTER_REPO carries no tag. # Image coordinates. REPO carries no tag.
FOODSTER_REPO=registry.example.com/you/foodster REPO=registry.example.com/you/foodster
FOODSTER_TAG=latest TAG=latest
# Shared household password. The app will not start without it. # Shared household password. The app will not start without it.
FOODSTER_PASSWORD=changeme PASSWORD=changeme
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no # Hostname Traefik routes to. Kept here rather than in compose.yaml so no
# infrastructure detail is committed. # infrastructure detail is committed.
FOODSTER_HOST=foodster.example.com HOST=foodster.example.com
# Anything other than prod is written into the browser tab title, so a dev
# instance open beside the real one can be told apart.
ENV=prod
# The database lives in ./data, bind-mounted into the container. These must # The database lives in ./data, bind-mounted into the container. These must
# match whoever owns that directory on the host, or the container cannot # match whoever owns that directory on the host, or the container cannot
# write to it. `id -u` and `id -g` will tell you. # write to it. `id -u` and `id -g` will tell you.
FOODSTER_UID=1000 #
FOODSTER_GID=1000 # Named PUID/PGID because UID is read-only in bash and a plain UID here would
# be quietly replaced by the invoking shell's own.
PUID=1000
PGID=1000
# Used for every calendar-day calculation. Set it in development too: under # Used for every calendar-day calculation. Set it in development too: under
# UTC the date rolls over three hours late, which is exactly when dinner # UTC the date rolls over three hours late, which is exactly when dinner
+9 -9
View File
@@ -38,7 +38,7 @@ build: generate ## Build ./foodster
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
run: generate ## Run locally on :8080 (database in ./data) run: generate ## Run locally on :8080 (database in ./data)
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG) PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG)
seed: ## Import a dish bundle (SEED=seeds/testi.json) seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED) go run $(PKG) -import $(SEED)
@@ -85,7 +85,7 @@ fix: ## Format Go and templ sources, tidy go.mod
go mod tidy go mod tidy
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
@# A release tag must point into main, or the tag records a commit that @# A release tag must point into main, or the tag records a commit that
@# was never released. @# was never released.
@branch=$$(git symbolic-ref --short HEAD); \ @branch=$$(git symbolic-ref --short HEAD); \
@@ -99,7 +99,7 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
echo "==> $$tag"; \ echo "==> $$tag"; \
git tag "$$tag"; \ git tag "$$tag"; \
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \ podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \ -t "$(REPO):$$tag" -t "$(REPO):latest" . ; \
echo "$$tag" > $(TAGFILE) echo "$$tag" > $(TAGFILE)
# Pushing reported success while uploading the previous release once, because # Pushing reported success while uploading the previous release once, because
@@ -107,18 +107,18 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
# registry what it actually serves for each tag and fail if it is not the # registry what it actually serves for each tag and fail if it is not the
# image we just built. # image we just built.
push: ## Push the newest tag and :latest, then verify the registry push: ## Push the newest tag and :latest, then verify the registry
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; } @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \ @test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
tag=$$(cat $(TAGFILE)); \ tag=$$(cat $(TAGFILE)); \
built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \ { echo "no local image tagged $$tag - run make image"; exit 1; }; \
podman push "$(FOODSTER_REPO):$$tag"; \ podman push "$(REPO):$$tag"; \
podman push "$(FOODSTER_REPO):latest"; \ podman push "$(REPO):latest"; \
echo "==> verifying $$tag"; \ echo "==> verifying $$tag"; \
for ref in "$$tag" latest; do \ for ref in "$$tag" latest; do \
podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \ podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \ { echo " FAIL $$ref is not in the registry"; exit 1; }; \
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \ served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
if [ "$$served" != "$$built" ]; then \ if [ "$$served" != "$$built" ]; then \
echo " FAIL $$ref serves $$served"; \ echo " FAIL $$ref serves $$served"; \
echo " expected $$built"; \ echo " expected $$built"; \
+13 -9
View File
@@ -351,7 +351,7 @@ build and no asset bundler.
is imported because the runtime image carries no zoneinfo. All date logic is imported because the runtime image carries no zoneinfo. All date logic
uses that location explicitly and never `time.Local`. uses that location explicitly and never `time.Local`.
- **Auth**: HTTP Basic with one shared household password read from - **Auth**: HTTP Basic with one shared household password read from
`FOODSTER_PASSWORD`; the username is ignored. Compared using `PASSWORD`; the username is ignored. Compared using
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor `subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
its length leaks through timing. `/healthz` is the only route outside auth. its length leaks through timing. `/healthz` is the only route outside auth.
- **Exposure**: the app is served on a public hostname behind Traefik, which - **Exposure**: the app is served on a public hostname behind Traefik, which
@@ -395,21 +395,25 @@ on the server and run with Docker Compose.
`/data/foodster.db`, bind-mounted from `./data` on the host rather than `/data/foodster.db`, bind-mounted from `./data` on the host rather than
kept in a named volume, so the file can be listed and copied without going kept in a named volume, so the file can be listed and copied without going
through the container engine. Backup is `cp -r data`. Because the image through the container engine. Backup is `cp -r data`. Because the image
runs as UID 65534, compose sets `user:` from `FOODSTER_UID`/`FOODSTER_GID` runs as UID 65534, compose sets `user:` from `PUID`/`PGID`
to match whoever owns that directory. `restart: unless-stopped`. to match whoever owns that directory. `restart: unless-stopped`.
- **Configuration**, entirely through environment variables (see - **Configuration**, entirely through environment variables (see
`.env.example`): `.env.example`):
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates. Names carry no application prefix: the container namespaces them already.
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to - `REPO` and `TAG` — image coordinates.
start without it. - `PASSWORD` — the shared password. Required; the app refuses to start
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The without it.
directory is created on startup if missing. - `DB` — database file path, default `./data/foodster.db`. The directory is
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`. created on startup if missing.
- `ENV` — anything but `prod` is prefixed to the browser tab title, so a
dev instance open beside the real one can be told apart.
- `PUID` / `PGID` — host owner of `./data`. Not `UID`, which is read-only
in bash and would be replaced by the invoking shell's own value.
- `TZ` — default `Europe/Helsinki`. - `TZ` — default `Europe/Helsinki`.
- The registry hostname exists only in `.env`, which is gitignored, because - The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository. §11 leaves open the possibility of publishing this repository.
- **Routing**: Traefik on an external `traefik` network, matching on - **Routing**: Traefik on an external `traefik` network, matching on
`FOODSTER_HOST` and terminating TLS. The container publishes no ports — `HOST` and terminating TLS. The container publishes no ports —
doing so would put an unencrypted copy of the app on the host, bypassing doing so would put an unencrypted copy of the app on the host, bypassing
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
infrastructure detail is committed. infrastructure detail is committed.
+15 -9
View File
@@ -173,13 +173,19 @@ Everything is environment variables. `.env` is gitignored; start from
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. | | `PASSWORD` | *required* | Shared password. The app will not start without it. |
| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | | `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. | | `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). |
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. |
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. |
| `FOODSTER_TAG` | `latest` | Tag to run under compose. | Names carry no prefix: the container gives them their own namespace already.
| `FOODSTER_PORT` | `8080` | Host port to publish. | `PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set
in `.env` would be silently replaced by the invoking shell's own.
| `REPO` | *required to build* | Image repository, no tag. |
| `TAG` | `latest` | Tag to run under compose. |
| `HOST` | *required to run* | Hostname Traefik routes to. |
Set `TZ` in development too. Under UTC the date rolls over three hours late, Set `TZ` in development too. Under UTC the date rolls over three hours late,
which is exactly when dinner gets logged. which is exactly when dinner gets logged.
@@ -204,7 +210,7 @@ the container, so a backup is `cp -r data` and you can inspect the file with
any sqlite client without going through the engine. any sqlite client without going through the engine.
That directory must exist and be owned by the user compose runs as — `make up` That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who creates it, and `PUID`/`PGID` in `.env` tell the container who
that is. Get them from `id -u` and `id -g`. that is. Get them from `id -u` and `id -g`.
If the app exits with `cannot open /data/foodster.db ... unable to open If the app exits with `cannot open /data/foodster.db ... unable to open
@@ -213,7 +219,7 @@ bind-mount directory as root, and the container is not root:
```sh ```sh
ls -ldn data # whose is it? ls -ldn data # whose is it?
sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID sudo chown -R 1000:1000 data # match PUID / PGID
docker compose restart docker compose restart
``` ```
@@ -235,7 +241,7 @@ counting it would lock the household out for simply opening the app.
address, meaning it arrived through the proxy. A client connecting directly address, meaning it arrived through the proxy. A client connecting directly
could otherwise forge a new address per attempt and skip the limiter. could otherwise forge a new address per attempt and skip the limiter.
**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes **None of this replaces a strong `PASSWORD`.** Rate limiting removes
brute force as a practical route; it does not make a guessable password safe. brute force as a practical route; it does not make a guessable password safe.
## Mockups ## Mockups
+23 -6
View File
@@ -35,6 +35,21 @@ var staticFS embed.FS
// version is replaced at build time with the CalVer tag (see `make image`). // version is replaced at build time with the CalVer tag (see `make image`).
var version = "dev" var version = "dev"
// envTag marks the browser tab of anything that is not production, so a dev
// instance and the real one open side by side are told apart at a glance.
// Empty in production, which is the default.
var envTag string
func setEnvTag(value string) {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "prod") || strings.EqualFold(value, "production") {
envTag = ""
return
}
// Whatever it says, so ENV=staging labels itself too.
envTag = strings.ToLower(value)
}
const ( const (
listenAddr = ":8080" listenAddr = ":8080"
defaultTZ = "Europe/Helsinki" defaultTZ = "Europe/Helsinki"
@@ -55,7 +70,7 @@ func run() error {
"import a JSON dish bundle (PRD §7.3 shape) and exit") "import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse() flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB)) db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB))
if err != nil { if err != nil {
return err return err
} }
@@ -66,9 +81,9 @@ func run() error {
return runImport(db, *importPath) return runImport(db, *importPath)
} }
password := os.Getenv("FOODSTER_PASSWORD") password := os.Getenv("PASSWORD")
if password == "" { if password == "" {
return errors.New("FOODSTER_PASSWORD is not set") return errors.New("PASSWORD is not set")
} }
// Fail rather than fall back to UTC: a silently wrong zone shifts logged // Fail rather than fall back to UTC: a silently wrong zone shifts logged
@@ -79,9 +94,11 @@ func run() error {
return fmt.Errorf("TZ: %w", err) return fmt.Errorf("TZ: %w", err)
} }
// The container always publishes :8080; FOODSTER_ADDR exists so tests and setEnvTag(os.Getenv("ENV"))
// a second local instance can pick another port.
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr) // The container always publishes :8080; ADDR exists so tests and a second
// local instance can pick another port.
addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
+23
View File
@@ -105,6 +105,29 @@ func TestReadSignals(t *testing.T) {
} }
} }
func TestEnvTagMarksNonProduction(t *testing.T) {
t.Cleanup(func() { envTag = "" })
cases := []struct{ env, want string }{
// Production is the default and must stay unmarked: the tag exists to
// pick the dev tab out of two identical ones.
{"", "Foodster"},
{"prod", "Foodster"},
{"PRODUCTION", "Foodster"},
{" ", "Foodster"},
{"dev", "dev · Foodster"},
{"DEV", "dev · Foodster"},
{"staging", "staging · Foodster"},
}
for _, c := range cases {
setEnvTag(c.env)
if got := pageTitle("Foodster"); got != c.want {
t.Errorf("ENV=%q: title = %q, want %q", c.env, got, c.want)
}
}
}
func TestMigrateCreatesSchema(t *testing.T) { func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db") db, err := openDB(t.TempDir() + "/test.db")
if err != nil { if err != nil {
+11 -1
View File
@@ -108,6 +108,16 @@ func categoryLabels(d Dish) string {
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }
// pageTitle prefixes the tab title on any instance that is not production.
// The tab is the only place a browser shows which of two identical apps you
// are looking at.
func pageTitle(title string) string {
if envTag == "" {
return title
}
return envTag + " · " + title
}
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive // countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
// after every number except one. // after every number except one.
func countFI(n int, one, many string) string { func countFI(n int, one, many string) string {
@@ -130,7 +140,7 @@ templ page(title, current string) {
// header rather than butting against it. // header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ title }</title> <title>{ pageTitle(title) }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and <!-- use-credentials: the manifest is fetched behind Basic auth and
+13 -9
View File
@@ -1,19 +1,23 @@
services: services:
app: app:
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest} image: ${REPO:?set REPO in .env}:${TAG:-latest}
restart: unless-stopped restart: unless-stopped
# The database is a bind mount, not a named volume: it sits in ./data on # A bind mount rather than a named volume: the database sits in ./data on
# the host where it can be listed, copied and opened with any sqlite # the host, where it can be listed, copied and backed up without going
# client. The image runs as UID 65534, so the container has to be told # through the container engine. The image runs as UID 65534, so the
# which host user owns that directory. # container has to be told which host user owns that directory.
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}" #
# PUID/PGID rather than UID/GID: UID is a read-only variable in bash, so a
# value set here would be silently replaced by the invoking shell's own.
user: "${PUID:-1000}:${PGID:-1000}"
volumes: volumes:
- ./data:/data - ./data:/data
environment: environment:
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env} PASSWORD: ${PASSWORD:?set PASSWORD in .env}
FOODSTER_DB: /data/foodster.db DB: /data/foodster.db
ENV: ${ENV:-prod}
TZ: ${TZ:-Europe/Helsinki} TZ: ${TZ:-Europe/Helsinki}
# No published ports: Traefik reaches the container over the shared # No published ports: Traefik reaches the container over the shared
@@ -22,7 +26,7 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.foodster.entrypoints=websecure - traefik.http.routers.foodster.entrypoints=websecure
- traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`) - traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`)
- traefik.http.routers.foodster.tls=true - traefik.http.routers.foodster.tls=true
- traefik.http.services.foodster.loadbalancer.server.port=8080 - traefik.http.services.foodster.loadbalancer.server.port=8080
- traefik.docker.network=traefik - traefik.docker.network=traefik
+3 -1
View File
@@ -16,7 +16,7 @@ trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
go build -o "$tmp/foodster" ./cmd/foodster go build -o "$tmp/foodster" ./cmd/foodster
FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \ PASSWORD="$pass" DB="$tmp/smoke.db" ADDR="$addr" \
"$tmp/foodster" >"$tmp/server.log" 2>&1 & "$tmp/foodster" >"$tmp/server.log" 2>&1 &
srv=$! srv=$!
@@ -72,6 +72,8 @@ check "theme script is served" \
home=$(curl -s -u ":$pass" "http://$addr/") home=$(curl -s -u ":$pass" "http://$addr/")
check "the header carries the brand" "$home" "Foodster" check "the header carries the brand" "$home" "Foodster"
# ENV is unset here, so this instance is production and unmarked.
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">' check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
check "the theme toggle is present" "$home" "data-theme-toggle" check "the theme toggle is present" "$home" "data-theme-toggle"
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"' check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'