The deployment is a public hostname behind Traefik rather than a LAN-only box, which changes two things. TLS is now terminated by the proxy, so Basic credentials are no longer in cleartext. The container publishes no ports: doing so would leave 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 and the MIT publication option stays open. The password is now the only thing between the internet and the app, and a 500 ms sleep is not a defence at that exposure. Wrong guesses are rate limited per client address: five in a burst, then one per ten seconds, answered with 429. Two details that decide whether this works at all: - a request with no Authorization header is not charged. That is the handshake every browser session opens with, and counting it would lock the household out for simply opening the app a few times. - X-Forwarded-For is believed only when the connection arrived from a private address, i.e. through the proxy, and then only its last entry, which is the one the proxy observed. A direct client could otherwise forge a fresh address per attempt and walk past the limiter entirely. None of this substitutes for a strong password. It removes brute force as a practical route, nothing more. PRD §3, §9 and §10 are updated: "no external internet exposure" is no longer true.
213 lines
7.7 KiB
Markdown
213 lines
7.7 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 sized
|
|
by how often they are eaten. Edit or delete the day's entry.
|
|
- **Historia** — every day back to the first entry, with unlogged days shown
|
|
as explicit gaps.
|
|
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
|
|
by paste or file upload. 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:
|
|
- 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` |
|
|
|
|
## Branches
|
|
|
|
`main` holds released versions only. Every release tag points at a commit on
|
|
`main`, so its history is the deployment history.
|
|
|
|
All development happens on `dev`. Merge into `main` when cutting a release,
|
|
then build and push the image from there.
|
|
|
|
```sh
|
|
git switch dev # where the work happens
|
|
git switch main && git merge dev && make release
|
|
```
|
|
|
|
## 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 **Ruoat** 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 |
|
|
|---|---|---|
|
|
| `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. |
|
|
| `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. |
|
|
|
|
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 `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
|
|
that is. Get them from `id -u` and `id -g`.
|
|
|
|
## 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 `FOODSTER_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).
|