Files
foodster/README.md
T
KessinenandEsa Kataja 71e413fbbb
release / image (push) Failing after 16s
check / check (push) Successful in 53s
release: authentication moves to Authelia (#5)
Authelia now runs in front of Traefik, so the app was asking for a second
password at the same door. This removes its own authentication entirely
rather than layering the two.

## Breaking — the server needs both files in this deploy

`compose.yaml` and `.env` are not pulled from this repository. The new image
ignores `PASSWORD`, and the old image refuses to start without it, so the
image and the compose file have to move together or the container dies at
startup.

| Variable | Change |
|---|---|
| `PASSWORD` | **removed** — the app no longer reads it |
| `AUTH` | **new, required** — the Traefik middleware that authenticates the app, e.g. `authelia@docker` |
| `CERTRESOLVER` | **new, required** — the resolver issuing the certificate for `HOST` |

The `tls=true` label is replaced by `tls.certresolver=${CERTRESOLVER}`.
Naming a resolver implies TLS, so it stays one label rather than two — and
the resolver had been carried by hand on the server since the first deploy.

## What was removed

- `auth()` and `challenge()` — HTTP Basic over a single shared password
- `throttle.go` and its tests — the per-IP guess limiter and the
  `X-Forwarded-For` handling that fed it
- `golang.org/x/time`, which existed only for that limiter
- Sixty `-u` flags from the smoke script

`routes()` returns the bare mux and `/healthz` is an ordinary route on it.
159 insertions against 446 deletions; nothing was written to replace what
went.

## What holds the app up now

Both invariants live in `compose.yaml`, next to comments saying why:

- **The router names the Authelia middleware through `AUTH`.** Traefik takes
  a router out of service when its middleware does not resolve, so an unset
  or misspelt value fails shut rather than serving the app open.
- **The container publishes no ports.** It is reachable only over the shared
  proxy network. Publishing `8080` would now bypass authentication outright,
  not merely TLS.

`/healthz` returns the version and nothing else, so it is safe to exempt in
Authelia if a monitor needs to reach it.

## Why this is stronger, not weaker

The layer being deleted was one shared secret with no sessions, no second
factor and no way to revoke access for one person. Authelia does all three,
configured once for every service on the host instead of reimplemented per
app. The weaker of the two prompts was the one being kept.

## Tests

`TestAuth` and `TestHealthzSkipsAuth` are replaced by a single test asserting
every route answers without credentials — a 401 from the app would now mean
authentication had crept back in. `make check` green; CI green on `dev`.

## Note on the commit list

Nine of the ten commits below are already in `main` via #4, squash-merged
under a different SHA. They contribute nothing to the diff, which is the
auth removal alone.

---------

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #5
2026-09-06 10:40:53 +00:00

288 lines
11 KiB
Markdown

# Foodster
A self-hosted dinner log and meal suggester for a single household. Record
what the family actually ate, and — later — let the app propose seven dinners
drawn from that history.
The interface is in Finnish. The code, comments and documentation are in
English.
See [PRD.md](PRD.md) for the full specification.
## Status
**Stage 1 — eating history: in use.** The meal catalog and the daily log came
first, because the suggester is worthless until there are a few weeks of real
history to weight against.
Working:
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are grouped
by category, then ordered and sized by how often they are eaten, so the
likely answer is the biggest target. The history sits on the same page
underneath: every day back to the first entry, unlogged days shown as
explicit gaps, and every row opening that day's logger in place. Older days
arrive a window at a time.
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
by paste or file upload. Grouped by category and alphabetical inside, since
this is a list you manage rather than one you pick from. Edit and delete are
row icons, and a delete asks first. Deletes are soft, so old log entries
keep showing the dish they used.
- **Search as you type** on both tabs, debounced, patching just the list.
- **Category icons**, not colour dots: shape and colour together, so two marks
are told apart by more than hue.
- **Light / dark**, remembered per device, dark by default. The button shows
the theme that is on — moon while dark, sun while light — not the one a
click would bring.
Nothing navigates. Every interaction patches the page through Datastar, so
the scroll position survives; links and forms still work with JavaScript off.
Still to build:
- Stage 2: the seven-meal suggester, which starts once there is history to
weight against.
## Stack
One static Go binary. No Node.js, no bundler, no separate database server.
| | |
|---|---|
| Language | Go 1.27 |
| HTTP | stdlib `net/http` + `http.ServeMux` |
| Views | [templ](https://templ.guide), server-rendered |
| Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script |
| Styling | hand-written CSS, `light-dark()` for themes |
| Database | SQLite via `modernc.org/sqlite` (pure Go) |
| Auth | none in-app — Authelia, via a Traefik forward-auth middleware |
| Runtime image | `FROM scratch` |
Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages,
and the conventions that are easy to miss.
## Branches
`main` holds released versions only. Every release tag points at a commit on
`main`, so its history is the deployment history. **Nothing is committed to
`main` directly** — it moves only by fast-forwarding `dev` into it.
All development happens on `dev`. `main` is protected on the remote: it takes
no direct pushes, so a release arrives through a pull request.
```sh
git switch dev # where the work happens
# ... commits ...
make check # lint, unit tests, smoke
git push origin dev # CI runs make check too
tea pr create --base main --head dev # or open it in the forge
# squash-merge the pull request — that is the whole release
```
Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and
`latest`, pushes both to the registry, and creates the matching git tag. There
is nothing to run locally afterwards; pull the new image on the server when
you are ready.
A release tag can therefore never point at a commit that was not released:
the workflow only runs on `main`, and `main` only moves through a pull
request.
## Quick start
```sh
cp .env.example .env # then edit it
make run # http://localhost:8080
```
`make` on its own lists every target:
```
make fix gofmt, templ fmt, go mod tidy
make lint go vet, gofmt check, golangci-lint when installed
make test go test ./...
make smoke end-to-end check against a scratch server
make check lint + test + smoke — run before every commit
make build ./foodster
make up/down/logs compose
```
Images are built by CI, not here — see [Deployment](#deployment).
### Occasional commands
Rare enough not to earn a `make` target. Both write into
`cmd/foodster/static/`, and the results are committed.
Re-download the vendored Datastar client after bumping the version:
```sh
curl -sSfL -o cmd/foodster/static/datastar.js \
"https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"
```
Re-rasterise the home-screen icons after editing `assets/icon.svg`:
```sh
cd cmd/foodster/static
rsvg-convert -w 180 -h 180 ../../../assets/icon.svg -o apple-touch-icon.png
rsvg-convert -w 192 -h 192 ../../../assets/icon.svg -o icon-192.png
rsvg-convert -w 512 -h 512 ../../../assets/icon.svg -o icon-512.png
oxipng -o max --zopfli --quiet apple-touch-icon.png icon-192.png icon-512.png
```
`oxipng -o max` on its own loses to optipng on the 512; `--zopfli` wins at
every size. Slow, but these are three tiny files built by hand.
## Importing dishes
The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
a file, and the app reports row by row what it did.
```json
{
"mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}],
"sides": [{"name": "Riisi"}]
}
```
Categories are `meat`, `chicken`, `fish` and `vegetarian` — stored in English
even though the UI is Finnish. A dish may list several, which is how
build-your-own meals like tortillas cover every category at once. `has_sides`
defaults to `true` when omitted.
Import is best-effort, never atomic. Valid rows land; invalid ones are skipped
and named (`tuntematon kategoria`, `ei kategorioita`, `jo listalla`).
Names are normalized to sentence case on the way in — whitespace collapses and
the first letter is capitalized, the rest is left as typed, because Finnish
capitalizes only the first word of a phrase. `keitetyt perunat` is stored as
`Keitetyt perunat`, while `BBQ-kylkeä` and `Kotipizza` keep their capitals.
Duplicates are caught case-insensitively, so re-importing a bundle is safe.
The same importer runs from the command line when you just want to repopulate
a scratch database:
```sh
go run ./cmd/foodster -import seeds/testi.json
```
## Icons
`cmd/foodster/static/favicon.svg` is the browser-tab icon and follows the
system theme. The home-screen icons are PNGs, because a home-screen icon
cannot be transparent and must not change with the theme; they are rasterised
from `assets/icon.svg`, which is opaque and keeps the artwork inside the
central 80% so Android can mask it to any shape.
The PNGs are committed so the build needs no rasterizer. The commands to
regenerate them are under [Occasional commands](#occasional-commands).
## Migrations
Numbered SQL files in `cmd/foodster/migrations`, embedded in the binary and
applied in filename order at startup. Each runs in a transaction and is
recorded in `schema_migrations`, so it applies exactly once.
Adding one means dropping a new `NNNN_what_it_does.sql` into that directory.
Never edit a migration that has already shipped — a released file has run on
a live database and will not run again.
## Configuration
Everything is environment variables. `.env` is gitignored; start from
`.env.example`.
| Variable | Default | Purpose |
|---|---|---|
| `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. |
| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. |
| `TAG` | `latest` | Tag to run under compose. |
| `HOST` | *required to run* | Hostname Traefik routes to. |
| `AUTH` | *required to run* | Traefik middleware that authenticates the app, e.g. `authelia@docker`. |
| `CERTRESOLVER` | *required to run* | Traefik certificate resolver for `HOST`. |
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.
Set `TZ` in development too. Under UTC the date rolls over three hours late,
which is exactly when dinner gets logged.
## Deployment
Images are built by CI when a pull request merges into `main`, and run under
Docker Compose on a LAN server. They are OCI images, so either engine works.
```sh
# on the server, once CI reports the build finished:
docker compose pull && docker compose up -d
```
Versions are CalVer — `vYYYYMMDD-N`, where `N` is the Nth build that day. The
running version is served at `GET /healthz`, which is the one route outside
authentication.
There is no database container. SQLite lives in `./data`, bind-mounted into
the container, so you can inspect the file with any sqlite client without
going through the engine. Back it up with
```sh
sqlite3 data/foodster.db ".backup data/foodster-$(date +%F).db"
```
rather than copying the directory: the database runs in WAL mode, and a plain
copy of a live database can catch the `.db` and its `-wal` mid-write.
That directory must exist and be owned by the user compose runs as — `make up`
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
database file (14)`, the ownership does not match. Docker creates a missing
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 PUID / PGID
docker compose restart
```
## Security
**The app has no authentication of its own.** It trusts every request it
receives, because the only thing that can reach it is Traefik, and Traefik
hands each request to Authelia first. Access control, sessions, brute-force
protection and multi-factor all live there, where they are configured once
for every service on the host instead of reimplemented per app.
Two things make that safe, and both must hold:
- **`AUTH` names the Authelia middleware** on the router. It is the whole of
the app's access control. Traefik takes a router out of service when its
middleware does not resolve, so a typo fails shut rather than open.
- **The container publishes no ports.** It is reachable only over the shared
`traefik` network. Publishing `8080` would put an unauthenticated,
unencrypted copy of the app on the host and defeat both of the above.
`/healthz` returns nothing but the version, so it is safe to bypass in
Authelia if a monitor needs to poll it from outside.
Earlier versions carried HTTP Basic auth and a per-IP guess limiter. Both
were removed once Authelia was in front: two prompts for one door, and the
weaker of the two was the one holding a shared password.
## Mockups
`mockups/` holds standalone HTML design studies. Open them directly in a
browser; they are references, not part of the build.
## License
[MIT](LICENSE).