diff --git a/.env.example b/.env.example index 4ac06c0..4c4f00c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Makefile b/Makefile index 6a5b8da..0468112 100644 --- a/Makefile +++ b/Makefile @@ -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"; \ diff --git a/PRD.md b/PRD.md index 6c4ee84..c7716d2 100644 --- a/PRD.md +++ b/PRD.md @@ -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. diff --git a/README.md b/README.md index 41eaf01..696f88b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 0ed12ee..96d1078 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -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, diff --git a/cmd/foodster/main_test.go b/cmd/foodster/main_test.go index 75bd898..0049626 100644 --- a/cmd/foodster/main_test.go +++ b/cmd/foodster/main_test.go @@ -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 { diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index d93c09f..49f4dbd 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -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. - { title } + { pageTitle(title) }