check / check (push) Successful in 2m14s
BREAKING CHANGE: PASSWORD is gone and AUTH and CERTRESOLVER are required. The server's compose.yaml and .env must be updated in the same deploy — the new image ignores PASSWORD, and the old one refuses to start without it. Authelia now sits in front of Traefik, so the app was asking for a second password at the same door. Two prompts, and the weaker of the two was the one holding a single shared secret with no sessions, no MFA and no revocation. Deleting it is the whole change: Authelia already does this properly, once, for every service on the host. Gone: auth(), challenge(), the whole of throttle.go and its tests, and golang.org/x/time with them. routes() returns the bare mux, /healthz is an ordinary route on it, and the smoke script drops sixty -u flags. Roughly 230 lines removed and nothing written to replace them. What holds the app up now, both asserted in compose.yaml: - The router names the Authelia middleware through AUTH. Traefik takes a router out of service when its middleware does not resolve, so a typo or an unset variable fails shut rather than serving the app open. - The container still publishes no ports, so the proxy is the only thing that can reach it. Publishing 8080 would now bypass authentication outright, not merely TLS — the comment there says so. certresolver replaces the bare tls=true, parameterised as CERTRESOLVER: the server had been carrying that label by hand since the first deploy. Naming a resolver implies tls=true, so it stays one label. TestAuth and TestHealthzSkipsAuth are replaced by one test asserting every route answers without credentials — a 401 from here would now mean auth had crept back in.
288 lines
11 KiB
Markdown
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).
|