Showstopper. Adding, editing or deleting a dish redirected to /ruoat, which stopped existing when the tab was renamed to Ruuat — every one of those actions ended on a 404. Live in v20260905-4. The tests missed it because they asserted only a 303; a redirect to a dead URL is still a 303. They now assert the target. The page no longer jumps. Deleting a dish partway down the catalog, or opening a day in Kirjaa, sent the browser to the top. Both now patch in place via Datastar — bin, pencil, day rows, dish pills, save, delete, cancel and Näytä lisää. Links stay links and forms stay forms, so it works without JavaScript. Non-production tabs are labelled. ENV=dev gives dev · Foodster. Contributing guide added, and commits now take Conventional Commit types. ⚠️ Breaking: rewrite the server's .env in this deploy. Environment variables lost the FOODSTER_ prefix; the app refuses to start on an unset PASSWORD. REPO=… TAG=latest PASSWORD=… HOST=foodster.kessinen.com ENV=prod PUID=1000 PGID=1000 TZ=Europe/Helsinki PUID/PGID rather than UID/GID — UID is read-only in bash and would be silently overwritten. Co-authored-by: Esa Kataja <[email protected]> Reviewed-on: #3
258 lines
9.9 KiB
Markdown
258 lines
9.9 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 development.** The meal catalog and the daily
|
|
log come 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 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 a link that
|
|
loads that day into the logger above it.
|
|
- **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. Deletes are soft,
|
|
so old log entries keep showing the dish they used.
|
|
- **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.
|
|
|
|
Still to build:
|
|
|
|
- Live search as you type, and paging for the history and catalog lists once
|
|
years of entries make them long. Both via Datastar.
|
|
- Category icons instead of plain colour dots — colour and shape together, so
|
|
a red blob and a yellow blob are told apart by more than hue.
|
|
- Edit and delete as icons in the catalog rows, and a confirmation step before
|
|
a delete actually happens.
|
|
- A background for the header. Something subtle; the palette gets overhauled
|
|
later.
|
|
- 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 | HTTP Basic, one shared household password |
|
|
| 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
|
|
|
|
tea pr create --base main --head dev # or open it in the forge
|
|
# merge the pull request, then:
|
|
|
|
git switch main && git pull --ff-only
|
|
make release # builds, tags vYYYYMMDD-N, pushes the image
|
|
git push origin --tags
|
|
```
|
|
|
|
`make image` additionally refuses to run from any branch but `main`, so a
|
|
release tag can never point at a commit that was not released. That check
|
|
lives locally because it has to: tags and images are built before anything
|
|
reaches the remote, so protection there cannot catch it.
|
|
|
|
## 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 build ./foodster
|
|
make seed import a dish bundle (SEED=seeds/testi.json)
|
|
make vendor re-download the Datastar client
|
|
make image build and tag vYYYYMMDD-N (creates a git tag)
|
|
make push push the newest tag and :latest
|
|
make release image + push
|
|
make up/down/logs compose
|
|
```
|
|
|
|
## 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
|
|
make seed # or: SEED=seeds/other.json make seed
|
|
```
|
|
|
|
## 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.
|
|
|
|
```sh
|
|
make icons # rsvg-convert, then optipng -o7
|
|
```
|
|
|
|
The PNGs are committed so the build needs no rasterizer. Re-run `make icons`
|
|
after editing `assets/icon.svg`.
|
|
|
|
## 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 |
|
|
|---|---|---|
|
|
| `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. |
|
|
|
|
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.
|
|
|
|
## Deployment
|
|
|
|
Images are built with Podman and run under Docker Compose on a LAN server.
|
|
They are OCI images, so either engine works.
|
|
|
|
```sh
|
|
make release # build, tag, push
|
|
# on the server:
|
|
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 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 `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
|
|
|
|
Access is a single shared password over HTTP Basic — no accounts, no
|
|
sessions. Credentials are compared in constant time over SHA-256 digests, so
|
|
neither the password nor its length leaks through timing.
|
|
|
|
The app is served on a public hostname behind Traefik, which terminates TLS,
|
|
so the credentials are encrypted in transit. That leaves the password as the
|
|
only thing between the internet and the app, so wrong guesses are rate
|
|
limited per client address: five in a burst, then one per ten seconds,
|
|
answered with `429`. Requests carrying no `Authorization` header are not
|
|
charged — that is the handshake every browser session begins with, and
|
|
counting it would lock the household out for simply opening the app.
|
|
|
|
`X-Forwarded-For` is trusted only when the connection came from a private
|
|
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 `PASSWORD`.** Rate limiting removes
|
|
brute force as a practical route; it does not make a guessable password safe.
|
|
|
|
## Mockups
|
|
|
|
`mockups/` holds standalone HTML design studies. Open them directly in a
|
|
browser; they are references, not part of the build.
|
|
|
|
## License
|
|
|
|
[MIT](LICENSE).
|