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
# must not end up in the repository.
# Image coordinates. FOODSTER_REPO carries no tag.
FOODSTER_REPO=registry.example.com/you/foodster
FOODSTER_TAG=latest
# Image coordinates. REPO carries no tag.
REPO=registry.example.com/you/foodster
TAG=latest
# 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
# 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
# match whoever owns that directory on the host, or the container cannot
# 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
# 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)
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)
go run $(PKG) -import $(SEED)
@@ -85,7 +85,7 @@ fix: ## Format Go and templ sources, tidy go.mod
go mod tidy
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
@# was never released.
@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"; \
git tag "$$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)
# 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
# image we just built.
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; }; \
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; }; \
podman push "$(FOODSTER_REPO):$$tag"; \
podman push "$(FOODSTER_REPO):latest"; \
podman push "$(REPO):$$tag"; \
podman push "$(REPO):latest"; \
echo "==> verifying $$tag"; \
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; }; \
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \
served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
if [ "$$served" != "$$built" ]; then \
echo " FAIL $$ref serves $$served"; \
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
uses that location explicitly and never `time.Local`.
- **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
its length leaks through timing. `/healthz` is the only route outside auth.
- **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
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
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`.
- **Configuration**, entirely through environment variables (see
`.env.example`):
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
start without it.
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The
directory is created on startup if missing.
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`.
Names carry no application prefix: the container namespaces them already.
- `REPO` and `TAG` — image coordinates.
- `PASSWORD` — the shared password. Required; the app refuses to start
without it.
- `DB` — database file path, default `./data/foodster.db`. The directory is
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`.
- The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository.
- **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
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
infrastructure detail is committed.
+15 -9
View File
@@ -173,13 +173,19 @@ Everything is environment variables. `.env` is gitignored; start from
| Variable | Default | Purpose |
|---|---|---|
| `FOODSTER_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. |
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. |
| `PASSWORD` | *required* | Shared password. The app will not start without it. |
| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
| `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. |
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. |
| `FOODSTER_TAG` | `latest` | Tag to run under compose. |
| `FOODSTER_PORT` | `8080` | Host port to publish. |
Names carry no prefix: the container gives them their own namespace already.
`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,
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.
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`.
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
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
```
@@ -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
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.
## 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`).
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 (
listenAddr = ":8080"
defaultTZ = "Europe/Helsinki"
@@ -55,7 +70,7 @@ func run() error {
"import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB))
db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB))
if err != nil {
return err
}
@@ -66,9 +81,9 @@ func run() error {
return runImport(db, *importPath)
}
password := os.Getenv("FOODSTER_PASSWORD")
password := os.Getenv("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
@@ -79,9 +94,11 @@ func run() error {
return fmt.Errorf("TZ: %w", err)
}
// The container always publishes :8080; FOODSTER_ADDR exists so tests and
// a second local instance can pick another port.
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
setEnvTag(os.Getenv("ENV"))
// 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{
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) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
+11 -1
View File
@@ -108,6 +108,16 @@ func categoryLabels(d Dish) string {
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
// after every number except one.
func countFI(n int, one, many string) string {
@@ -130,7 +140,7 @@ templ page(title, current string) {
// 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: dark)" content="#1C1E22"/>
<title>{ title }</title>
<title>{ pageTitle(title) }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and
+13 -9
View File
@@ -1,19 +1,23 @@
services:
app:
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
image: ${REPO:?set REPO in .env}:${TAG:-latest}
restart: unless-stopped
# The database is a bind mount, not a named volume: it sits in ./data on
# the host where it can be listed, copied and opened with any sqlite
# client. The image runs as UID 65534, so the container has to be told
# which host user owns that directory.
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
# A bind mount rather than a named volume: the database sits in ./data on
# the host, where it can be listed, copied and backed up without going
# through the container engine. The image runs as UID 65534, so the
# container has to be told which host user owns that directory.
#
# 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:
- ./data:/data
environment:
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
FOODSTER_DB: /data/foodster.db
PASSWORD: ${PASSWORD:?set PASSWORD in .env}
DB: /data/foodster.db
ENV: ${ENV:-prod}
TZ: ${TZ:-Europe/Helsinki}
# No published ports: Traefik reaches the container over the shared
@@ -22,7 +26,7 @@ services:
labels:
- traefik.enable=true
- 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.services.foodster.loadbalancer.server.port=8080
- 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
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 &
srv=$!
@@ -72,6 +72,8 @@ check "theme script is served" \
home=$(curl -s -u ":$pass" "http://$addr/")
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 "the theme toggle is present" "$home" "data-theme-toggle"
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'