5 Commits
Author SHA1 Message Date
KessinenandEsa Kataja 71e413fbbb release: authentication moves to Authelia (#5)
release / image (push) Failing after 16s
check / check (push) Successful in 53s
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
KessinenandEsa Kataja 57d5faf65f release: open days older than the first entry ever logged (#4)
release / image (push) Failing after 5s
## Fixed

- Days older than the first entry ever logged can now be opened. Picking a
  date from before anything was recorded landed on a dead end.

## Changed

- Releases are built by CI. Merging this pull request builds the image,
  tags it `vYYYYMMDD-N` and `latest`, pushes both, and creates the git
  tag. Nothing is built locally any more.
- `make image`, `push`, `release`, `seed`, `icons` and `vendor` are gone;
  the Makefile is down from 151 lines to 71. The two rare commands are
  written out in the README.
- Every push to `dev` now runs `make check` in CI.

## Deployment

No new or renamed environment variables, and `compose.yaml` is unchanged —
nothing to copy to the server this time. Pull the new image once CI reports
the build finished.

---------

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #4
2026-09-06 08:28:31 +00:00
KessinenandEsa Kataja d8810d288e release: repair the catalog 404 and stop the page jumping (#3)
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
2026-09-05 21:03:04 +00:00
KessinenandEsa Kataja 174652778b Release: leftovers, the real dish list, and a day list that stays put (#2)
Tähteet — leftovers. Not a dish: it exists so a day can be recorded as "we ate what was already there" without inventing a meal nobody cooked. A special-flagged row created by migration 0002; never in the catalog, never editable, and excluded from the stage-2 suggester outright. It gets its own mark, a grey lidded tub — which exposed a bug where no categories drew the same icon as several.

The real dish list. The seed bundle is now your December 2024 list: 37 mains, 9 sides. Letut and Pannari dropped as not-dinners, Kanakintut folded into Broilerin koipireidet, has_sides assigned by rule.

The day list stays put. Choosing a day used to swap a panel in above the list and drop that day out of it, so rows below jumped up under the tap. The list is now the page; the selected day expands where it sits, with anchors so the viewport lands on the day rather than the top.

Release pipeline fixed. The last release silently pushed the previous image: release declared image and push as prerequisites and your make runs -j16, so they raced. Also, push re-derived the tag by date-sorting, which is ambiguous when two tags share a commit. It now reads what image recorded, and verifies afterwards that the registry serves what was built.

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #2
2026-09-05 20:24:02 +00:00
KessinenandEsa Kataja e9754488db Release: one log page, grouped dishes, live search (#1)
Structure
- Kirjaa and Historia are one page. They were two views of the same thing — every history row already linked into the logger, and the logger had a day switcher. Two tabs instead of three. Also closed a gap: on an already-logged day there was no way to swap to a different dish, only to re-pick its sides.
- Ruoat → Ruuat, label and route.
- The catalog has a structure. It had no top-level headings at all — the mains simply began with "Liha". Both halves now carry a heading and a count, categories are visibly subordinate, and the add/edit forms collapse instead of filling the screen before any content.

Finding things
- Dishes grouped by category on both screens, Sekalaiset for multi-category ones. Derived from the stored set, not a fifth category, so one Tortillat still covers all four for the §8.1 suggester later.
- Live search on both lists, 250 ms after typing stops. Both remain plain GET forms, so they still filter with JavaScript off.
- History is paged 30 days at a time — it previously rendered every day back to the first entry, forever.

Correctness
- Future meals refused. The picker offered them and ?pvm= accepted them.
- today() wasn't midnight, so it never equalled a date parsed from ?pvm= — after saving, the card read "la 5.9. kirjattu" instead of "Tänään kirjattu".
- Deletes ask first, for dishes and logged meals. The meal is the more destructive: a dish is only soft-deleted.
- DB open failures name the path and uid, instead of unable to open database file (14).

Visual
- Category icons replace colour dots — steak, drumstick, fish, leaf, quartered circle.
- Row actions are a pencil and a bin; the header has a surface.

Housekeeping
- Datastar SDK dropped — one JSON decode was pulling in four modules including an HTTP compression stack. Five lines replace it.
- Release policy documented: main protected, releases arrive as PRs.

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #1
2026-09-05 19:28:54 +00:00
24 changed files with 1467 additions and 777 deletions
+21 -9
View File
@@ -1,22 +1,34 @@
# Copy to .env and fill in. .env is gitignored — the real registry hostname # Copy to .env and fill in. .env is gitignored — the real registry hostname
# must not end up in the repository. # must not end up in the repository.
# Image coordinates. FOODSTER_REPO carries no tag. # Image coordinates. REPO carries no tag.
FOODSTER_REPO=registry.example.com/you/foodster REPO=registry.example.com/you/foodster
FOODSTER_TAG=latest TAG=latest
# Shared household password. The app will not start without it.
FOODSTER_PASSWORD=changeme
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no # Hostname Traefik routes to. Kept here rather than in compose.yaml so no
# infrastructure detail is committed. # infrastructure detail is committed.
FOODSTER_HOST=foodster.example.com HOST=foodster.example.com
# The Traefik middleware that authenticates the app. The app itself has no
# login, so this is the whole of its access control — an unset or misspelt
# name takes the router out of service, which is the right way to fail.
AUTH=authelia@docker
# Traefik certificate resolver issuing the TLS certificate for HOST.
CERTRESOLVER=letsencrypt
# 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 # The database lives in ./data, bind-mounted into the container. These must
# match whoever owns that directory on the host, or the container cannot # match whoever owns that directory on the host, or the container cannot
# write to it. `id -u` and `id -g` will tell you. # 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 # 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 # UTC the date rolls over three hours late, which is exactly when dinner
+22
View File
@@ -0,0 +1,22 @@
name: check
on:
push:
branches: [dev]
# ponytail: only because Traefik still serves its default self-signed cert for
# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run.
env:
GIT_SSL_NO_VERIFY: "true"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: make check
+58
View File
@@ -0,0 +1,58 @@
name: release
on:
push:
branches: [main]
# ponytail: only because Traefik still serves its default self-signed cert for
# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run.
env:
GIT_SSL_NO_VERIFY: "true"
REGISTRY: git.kessinen.com
IMAGE: git.kessinen.com/kessinen/foodster
jobs:
image:
runs-on: ubuntu-latest
steps:
# Full history and tags: the release number is derived by counting the
# tags already cut today.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The job container is node:22-bookworm and has no docker client. The
# static binary is one file; installing docker.io would pull a daemon
# that is never used, since the build runs against the host's.
- name: Install the docker client
run: |
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
docker version --format '{{.Client.Version}}'
- name: Work out the release tag
id: rel
run: |
day=$(date +%Y%m%d)
tag="v$day-$(( $(git tag -l "v$day-*" | wc -l) + 1 ))"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "==> $tag"
- name: Tag the commit
run: |
git tag "${{ steps.rel.outputs.tag }}"
git push origin "${{ steps.rel.outputs.tag }}"
- name: Log in to the registry
run: |
echo "${{ secrets.GITEA_TOKEN }}" \
| docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin
- name: Build and push
run: |
tag="${{ steps.rel.outputs.tag }}"
docker build --platform linux/amd64 --build-arg VERSION="$tag" \
-f Containerfile \
-t "$IMAGE:$tag" -t "$IMAGE:latest" .
docker push "$IMAGE:$tag"
docker push "$IMAGE:latest"
echo "pushed $IMAGE:$tag and :latest - pull it in dockge when ready"
+104
View File
@@ -0,0 +1,104 @@
# Contributing
A household project, so this is less a set of rules than a note to whoever
picks it up next — including me in six months.
## Getting set up
```sh
cp .env.example .env # then edit it
make run # http://localhost:8080, tab titled "dev · Foodster"
make # every target, with a one-line description
```
`make check` is the gate: `go vet`, gofmt, unit tests, and `scripts/smoke.sh`,
which drives a real server over HTTP. Run it before every commit.
## Branches
`dev` is where work happens. `main` holds released versions only — it is
protected on the remote and takes no direct pushes, so a release arrives as a
pull request from `dev`, squash-merged.
After a squash merge, reset `dev` onto it or the next pull request will offer
the same commits again:
```sh
git switch main && git pull --ff-only
git switch dev && git reset --hard main
git push --force-with-lease origin dev
```
The release workflow only triggers on `main`, and `main` only moves through a
pull request, so a release can never be built from the wrong branch. Nothing
needs to check for it.
## Commit messages
Conventional Commits — a type, an optional scope, then a short subject in the
imperative.
```
feat(kirjaa): expand the selected day in place
fix: redirect the catalog to /ruuat, not /ruoat
chore(deps): bump the vendored Datastar client
```
| Type | For |
|---|---|
| `feat` | new behaviour someone will notice |
| `fix` | a bug, ideally naming what broke |
| `refactor` | same behaviour, different shape |
| `test` | tests only |
| `docs` | documentation only |
| `build` | Makefile, Containerfile, compose, CI |
| `chore` | anything else: dependencies, seeds, tidying |
**The body matters more than the type.** Explain *why*, and what the
alternative was — the diff already says what changed. If a fix was subtle,
say what made it subtle; if a test caught something, say what. Commits here
are the only design record this project has.
### Release pull requests
Because `main` is squash-merged, a pull request title becomes a commit message
on `main`. A release spans a fix, a feature and some chores at once, so none
of the types above fits it honestly. Use `release:` instead:
```
release: repair the catalog 404 and stop the page jumping
```
`main`'s log is then one line per deployment, which is what that branch is
for, and the pull request body serves as the release notes. No version in the
title — CI creates the CalVer tag after the merge, so it is not known yet.
The types above are for `dev`, where a commit really does do one thing.
## Deploying
The server keeps its own `compose.yaml` and `.env`. Neither is pulled from
here, so a release that renames a variable, adds one, or changes a mount
needs both copied across **in the same deploy** — otherwise the container
comes up against the old names and the app refuses to start.
Anything in this repository that reaches the server by hand belongs in the
release notes, flagged as breaking.
## Things that are easy to get wrong
- **The interface is Finnish.** Code, comments, this file and the PRD are
English. There is no i18n layer and no language switcher.
- **No infrastructure detail is committed** — no hostnames, registry paths or
ports. They live in `.env`, which is gitignored, because the PRD leaves the
door open to publishing this repository.
- **Migrations are immutable once shipped.** A released migration has run on a
live database and will not run again. Add a new numbered file instead.
- **Interactions patch, they do not navigate.** Anything that reloads the page
loses the scroll position, which on a long list is maddening. Links stay
links and forms stay forms so it works without JavaScript; Datastar layers
over them with `data-on:click__prevent` and `data-on:submit__prevent`.
- **A `ponytail:` comment marks a deliberate shortcut** and names its ceiling,
so the next reader can tell a decision from an oversight.
- **Assert what a response does, not just that it responded.** A redirect to a
dead URL is still a 303; that one shipped.
+3 -52
View File
@@ -3,13 +3,8 @@
COMPOSE ?= podman compose COMPOSE ?= podman compose
BIN := foodster BIN := foodster
PKG := ./cmd/foodster PKG := ./cmd/foodster
STATIC := cmd/foodster/static
# Vendored Datastar client. Bump, run `make vendor`, commit the result. # Registry coordinates, hostname, TZ. Gitignored.
DATASTAR_VERSION ?= v1.0.3
SEED ?= seeds/testi.json
# Registry coordinates, shared password and TZ live here. Gitignored.
ifneq (,$(wildcard .env)) ifneq (,$(wildcard .env))
include .env include .env
export export
@@ -19,7 +14,7 @@ endif
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean .PHONY: help generate build run test smoke check lint fix up down logs clean
help: ## Show this help help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
@@ -33,10 +28,7 @@ build: generate ## Build ./foodster
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
run: generate ## Run locally on :8080 (database in ./data) run: generate ## Run locally on :8080 (database in ./data)
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG) ENV=dev go run $(PKG)
seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED)
test: generate ## Run unit tests test: generate ## Run unit tests
go test ./... go test ./...
@@ -52,21 +44,6 @@ check: ## Everything that must pass before a commit
@$(MAKE) --no-print-directory smoke @$(MAKE) --no-print-directory smoke
@echo "check: all passed" @echo "check: all passed"
icons: ## Rasterise home-screen PNGs from assets/icon.svg and optimise them
rsvg-convert -w 180 -h 180 assets/icon.svg -o $(STATIC)/apple-touch-icon.png
rsvg-convert -w 192 -h 192 assets/icon.svg -o $(STATIC)/icon-192.png
rsvg-convert -w 512 -h 512 assets/icon.svg -o $(STATIC)/icon-512.png
# oxipng -o max alone loses to optipng on the 512; --zopfli wins at every
# size. Slow, but these are three tiny files built by hand.
oxipng -o max --zopfli --quiet \
$(STATIC)/apple-touch-icon.png $(STATIC)/icon-192.png $(STATIC)/icon-512.png
@ls -l $(STATIC)/*.png
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
curl -sSfL -o $(STATIC)/datastar.js \
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
@head -1 $(STATIC)/datastar.js
lint: generate ## go vet, gofmt check, golangci-lint when installed lint: generate ## go vet, gofmt check, golangci-lint when installed
go vet ./... go vet ./...
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
@@ -79,32 +56,6 @@ fix: ## Format Go and templ sources, tidy go.mod
go tool templ fmt . go tool templ fmt .
go mod tidy 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; }
@# A release tag must point into main, or the tag records a commit that
@# was never released.
@branch=$$(git symbolic-ref --short HEAD); \
if [ "$$branch" != "main" ]; then \
echo "releases are cut from main, not $$branch:"; \
echo " git switch main && git merge --ff-only dev"; \
exit 1; \
fi
@day=$$(date +%Y%m%d); \
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
echo "==> $$tag"; \
git tag "$$tag"; \
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" .
push: ## Push the newest tag and :latest
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
@tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \
test -n "$$tag" || { echo "no tags yet - run make image"; exit 1; }; \
podman push "$(FOODSTER_REPO):$$tag"; \
podman push "$(FOODSTER_REPO):latest"
release: image push ## Build, tag and push in one go
up: ## Start the stack up: ## Start the stack
@mkdir -p data # or the engine creates it root-owned and the app cannot write @mkdir -p data # or the engine creates it root-owned and the app cannot write
$(COMPOSE) up -d $(COMPOSE) up -d
+73 -44
View File
@@ -25,12 +25,12 @@ polished, it may be released as FOSS under MIT.
- No grocery list generation (possible future add-on). - No grocery list generation (possible future add-on).
- No per-recipe ingredient tracking — meals are just names. - No per-recipe ingredient tracking — meals are just names.
- No calendar/scheduling with times, reminders, or calendar exports. - No calendar/scheduling with times, reminders, or calendar exports.
- No user accounts, per-person profiles, or permissions. A single shared - No user accounts, per-person profiles, or permissions *in the app*.
password gates the whole app (§9). Authentication is the reverse proxy's job (§9).
- No nutrition tracking, calorie counting, or dietary-goal optimization. - No nutrition tracking, calorie counting, or dietary-goal optimization.
- No mobile-native apps. Web only (mobile-friendly responsive is enough). - No mobile-native apps. Web only (mobile-friendly responsive is enough).
- No per-user accounts or sessions. The app *is* reachable from the internet - No per-user accounts or sessions in the app. It *is* reachable from the
(§9, §10), gated by a single shared password over TLS. internet (§9, §10), behind Authelia at the proxy.
## 4. Delivery stages ## 4. Delivery stages
@@ -72,9 +72,9 @@ weighting to be meaningful (a few weeks of logged meals).
## 5. Users ## 5. Users
A single household. One shared instance, no per-person accounts. Anyone on the A single household. One shared instance, no per-person accounts. Everyone who
home network who knows the shared password can open the app and interact with gets past Authelia sees and edits the same log; the app draws no distinction
it. between them.
The interface is written in **Finnish** — every user of this instance is a The interface is written in **Finnish** — every user of this instance is a
Finnish speaker, so there is no i18n layer and no language switcher. Strings Finnish speaker, so there is no i18n layer and no language switcher. Strings
@@ -113,6 +113,23 @@ in English.
Side dishes live in their own table and have no category. The pool is Side dishes live in their own table and have no category. The pool is
expected to stay small. expected to stay small.
### Tähteet — leftovers (stage 1)
A single built-in entry, flagged `special` on the main dish table. It is
**not food**: it exists so a day can be recorded as "we ate what was already
there" without inventing a meal that was never cooked.
- No category, which is why it cannot be an ordinary main: those must have
at least one.
- Created by a migration. The household does not add, edit or delete it, and
it never appears in the Ruuat catalog.
- Loggable exactly like any other entry, and shown on the log board apart
from the categories.
- **The stage 2 suggester must never propose it.** It is excluded from the
eligible pool outright, so cooldown, category coverage (§8.1) and
frequency weighting (§8.2) all skip it — despite it being among the
most-logged entries.
### Meal log entry ("what was actually eaten") (stage 1) ### Meal log entry ("what was actually eaten") (stage 1)
- `id` - `id`
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field - `date` — SQL `DATE`, day granularity only. There is no time-of-day field
@@ -333,21 +350,21 @@ build and no asset bundler.
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata` to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
is imported because the runtime image carries no zoneinfo. All date logic is imported because the runtime image carries no zoneinfo. All date logic
uses that location explicitly and never `time.Local`. uses that location explicitly and never `time.Local`.
- **Auth**: HTTP Basic with one shared household password read from - **Auth**: none in the app. Every route is served unauthenticated, because
`FOODSTER_PASSWORD`; the username is ignored. Compared using the only client that can reach the app is Traefik, which forwards each
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor request to **Authelia** first. Sessions, brute-force protection and
its length leaks through timing. `/healthz` is the only route outside auth. multi-factor are configured there once for every service on the host.
- **Exposure**: the app is served on a public hostname behind Traefik, which Deliberately not reimplemented per app: the earlier in-app HTTP Basic layer
terminates TLS, so Basic credentials are encrypted in transit. A shared meant two prompts for one door, and the weaker of the two was the one
password is therefore the only thing between the internet and the app, and holding a shared password.
it is guarded by a per-address rate limiter: five wrong guesses, then one - **Exposure**: served on a public hostname behind Traefik, which terminates
per ten seconds, answered with `429`. Only requests that actually present TLS. Two invariants carry the whole security model, and both are asserted
a wrong password spend the allowance — a request with no `Authorization` in `compose.yaml`. The router names the Authelia middleware through `AUTH`
header is the normal browser handshake that opens every session. — unset or misspelt, Traefik takes the router out of service, so a typo
`X-Forwarded-For` is trusted only when the connection arrived from a fails shut. And the container publishes no ports, so it is reachable only
private address, so a direct client cannot forge a new identity per over the shared proxy network; publishing `8080` would expose an
attempt. None of this substitutes for a strong password; it only removes unauthenticated plaintext copy on the host. `/healthz` returns only the
brute force as a practical route. version and is safe to bypass in Authelia for monitoring.
- **Containers**: built with Podman in development, run under Docker Compose - **Containers**: built with Podman in development, run under Docker Compose
in production. Images are OCI, so one image works with both engines. in production. Images are OCI, so one image works with both engines.
@@ -355,22 +372,27 @@ Explicitly *not* React.
## 10. Deployment ## 10. Deployment
Images are built locally, pushed to a private container registry, then pulled Images are built by CI, pushed to a private container registry, then pulled on
on the server and run with Docker Compose. the server and run with Docker Compose.
- **Branches**: `main` carries released versions only, so its history is the - **Branches**: `main` carries released versions only, so its history is the
deployment history and every release tag points into it. Development happens deployment history and every release tag points into it. Development happens
on `dev`, and `main` is protected on the remote: it accepts no direct on `dev`, and `main` is protected on the remote: it accepts no direct
pushes, so a release arrives as a pull request from `dev`. `make image` pushes, so a release arrives as a pull request from `dev`. The release
additionally refuses to run outside `main` — that one has to be local, workflow runs only on `main`, so a release cannot be built from anywhere
because the tag and the image are made before anything reaches the remote. else and nothing needs to check for it.
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that - **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
day. `make image` derives `N` by counting the day's existing git tags, day. The release workflow derives `N` by counting the day's existing git
creates the new tag, and bakes the version into the binary through tags, creates the new tag, and bakes the version into the binary through
`-ldflags -X main.version`. `make release` builds, tags and pushes. `-ldflags -X main.version`.
- **Tooling**: a `Makefile` is the single entry point — `make` on its own - **CI**: Gitea Actions, workflows in `.gitea/workflows/`. `check.yaml` runs
lists every target. Build, test, lint, format, image and compose commands `make check` on every push to `dev`; `release.yaml` builds and pushes the
all live there rather than in loose scripts. image when a pull request merges into `main`. Merging is the release —
there is no local build step.
- **Tooling**: a `Makefile` covers development — `make` on its own lists every
target. Build, test, lint, format and compose commands live there rather
than in loose scripts. Commands run a handful of times a year are written
out in the README instead of earning a target.
- **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a - **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a
static binary; the runtime stage is `FROM scratch` holding only that static binary; the runtime stage is `FROM scratch` holding only that
binary, running as UID 65534. binary, running as UID 65534.
@@ -378,33 +400,40 @@ on the server and run with Docker Compose.
`/data/foodster.db`, bind-mounted from `./data` on the host rather than `/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 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 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`. to match whoever owns that directory. `restart: unless-stopped`.
- **Configuration**, entirely through environment variables (see - **Configuration**, entirely through environment variables (see
`.env.example`): `.env.example`):
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates. Names carry no application prefix: the container namespaces them already.
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to - `REPO` and `TAG` — image coordinates.
start without it. - `AUTH` — the Traefik middleware that authenticates the app, e.g.
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The `authelia@docker`. Required; it is the app's only access control.
directory is created on startup if missing. - `HOST` and `CERTRESOLVER` — the hostname Traefik matches on and the
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`. resolver that issues its certificate.
- `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`. - `TZ` — default `Europe/Helsinki`.
- The registry hostname exists only in `.env`, which is gitignored, because - The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository. §11 leaves open the possibility of publishing this repository.
- **Routing**: Traefik on an external `traefik` network, matching on - **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 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 the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
infrastructure detail is committed. infrastructure detail is committed.
- **Health**: `GET /healthz` returns the build version and is exempt from - **Health**: `GET /healthz` returns the build version and nothing else, so it
auth. There is no Docker `HEALTHCHECK` directive, because a `scratch` image is safe to exempt in Authelia. There is no Docker `HEALTHCHECK` directive,
because a `scratch` image
has no shell to run one and `restart: unless-stopped` already covers a dead has no shell to run one and `restart: unless-stopped` already covers a dead
process. Adding one would mean giving the binary a `-healthcheck` flag that process. Adding one would mean giving the binary a `-healthcheck` flag that
calls its own endpoint. calls its own endpoint.
- Pending migrations are applied on app start. - Pending migrations are applied on app start.
- The Datastar client is vendored at `cmd/foodster/static/datastar.js` and - The Datastar client is vendored at `cmd/foodster/static/datastar.js` and
served from the app's own origin — the SDK ships no browser asset, and a served from the app's own origin — the SDK ships no browser asset, and a
CDN link would break an offline LAN. `make vendor` refreshes it; the pinned CDN link would break an offline LAN. The README says how to refresh it; the pinned
version lives in the `Makefile` and in the file's first line. version lives in the `Makefile` and in the file's first line.
- No internet exposure; the server binds to the LAN. - No internet exposure; the server binds to the LAN.
+109 -70
View File
@@ -11,35 +11,35 @@ See [PRD.md](PRD.md) for the full specification.
## Status ## Status
**Stage 1 — eating history: in development.** The meal catalog and the daily **Stage 1 — eating history: in use.** The meal catalog and the daily log came
log come first, because the suggester is worthless until there are a few first, because the suggester is worthless until there are a few weeks of real
weeks of real history to weight against. history to weight against.
Working: Working:
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered - **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are grouped
and sized by how often they are eaten, so the likely answer is the biggest by category, then ordered and sized by how often they are eaten, so the
target. The history sits on the same page underneath: every day back to the likely answer is the biggest target. The history sits on the same page
first entry, unlogged days shown as explicit gaps, and every row a link that underneath: every day back to the first entry, unlogged days shown as
loads that day into the logger above it. 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 - **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 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, this is a list you manage rather than one you pick from. Edit and delete are
so old log entries keep showing the dish they used. 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 - **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 the theme that is on — moon while dark, sun while light — not the one a
click would bring. 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: 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 - Stage 2: the seven-meal suggester, which starts once there is history to
weight against. weight against.
@@ -55,9 +55,12 @@ One static Go binary. No Node.js, no bundler, no separate database server.
| Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script | | Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script |
| Styling | hand-written CSS, `light-dark()` for themes | | Styling | hand-written CSS, `light-dark()` for themes |
| Database | SQLite via `modernc.org/sqlite` (pure Go) | | Database | SQLite via `modernc.org/sqlite` (pure Go) |
| Auth | HTTP Basic, one shared household password | | Auth | none in-app — Authelia, via a Traefik forward-auth middleware |
| Runtime image | `FROM scratch` | | Runtime image | `FROM scratch` |
Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages,
and the conventions that are easy to miss.
## Branches ## Branches
`main` holds released versions only. Every release tag points at a commit on `main` holds released versions only. Every release tag points at a commit on
@@ -71,20 +74,20 @@ no direct pushes, so a release arrives through a pull request.
git switch dev # where the work happens git switch dev # where the work happens
# ... commits ... # ... commits ...
make check # lint, unit tests, smoke make check # lint, unit tests, smoke
git push origin dev git push origin dev # CI runs make check too
tea pr create --base main --head dev # or open it in the forge tea pr create --base main --head dev # or open it in the forge
# merge the pull request, then: # squash-merge the pull request — that is the whole release
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 Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and
release tag can never point at a commit that was not released. That check `latest`, pushes both to the registry, and creates the matching git tag. There
lives locally because it has to: tags and images are built before anything is nothing to run locally afterwards; pull the new image on the server when
reaches the remote, so protection there cannot catch it. 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 ## Quick start
@@ -99,15 +102,39 @@ make run # http://localhost:8080
make fix gofmt, templ fmt, go mod tidy make fix gofmt, templ fmt, go mod tidy
make lint go vet, gofmt check, golangci-lint when installed make lint go vet, gofmt check, golangci-lint when installed
make test go test ./... 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 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 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 ## Importing dishes
The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
@@ -138,7 +165,7 @@ The same importer runs from the command line when you just want to repopulate
a scratch database: a scratch database:
```sh ```sh
make seed # or: SEED=seeds/other.json make seed go run ./cmd/foodster -import seeds/testi.json
``` ```
## Icons ## Icons
@@ -149,12 +176,8 @@ 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 from `assets/icon.svg`, which is opaque and keeps the artwork inside the
central 80% so Android can mask it to any shape. central 80% so Android can mask it to any shape.
```sh The PNGs are committed so the build needs no rasterizer. The commands to
make icons # rsvg-convert, then optipng -o7 regenerate them are under [Occasional commands](#occasional-commands).
```
The PNGs are committed so the build needs no rasterizer. Re-run `make icons`
after editing `assets/icon.svg`.
## Migrations ## Migrations
@@ -173,25 +196,31 @@ Everything is environment variables. `.env` is gitignored; start from
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. | | `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
| `FOODSTER_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`). |
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. | | `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. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. | | `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. |
| `FOODSTER_TAG` | `latest` | Tag to run under compose. | | `TAG` | `latest` | Tag to run under compose. |
| `FOODSTER_PORT` | `8080` | Host port to publish. | | `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, Set `TZ` in development too. Under UTC the date rolls over three hours late,
which is exactly when dinner gets logged. which is exactly when dinner gets logged.
## Deployment ## Deployment
Images are built with Podman and run under Docker Compose on a LAN server. Images are built by CI when a pull request merges into `main`, and run under
They are OCI images, so either engine works. Docker Compose on a LAN server. They are OCI images, so either engine works.
```sh ```sh
make release # build, tag, push # on the server, once CI reports the build finished:
# on the server:
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
@@ -200,11 +229,18 @@ running version is served at `GET /healthz`, which is the one route outside
authentication. authentication.
There is no database container. SQLite lives in `./data`, bind-mounted into 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 the container, so you can inspect the file with any sqlite client without
any sqlite client without going through the engine. 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` 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`. that is. Get them from `id -u` and `id -g`.
If the app exits with `cannot open /data/foodster.db ... unable to open If the app exits with `cannot open /data/foodster.db ... unable to open
@@ -213,30 +249,33 @@ bind-mount directory as root, and the container is not root:
```sh ```sh
ls -ldn data # whose is it? 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 docker compose restart
``` ```
## Security ## Security
Access is a single shared password over HTTP Basic — no accounts, no **The app has no authentication of its own.** It trusts every request it
sessions. Credentials are compared in constant time over SHA-256 digests, so receives, because the only thing that can reach it is Traefik, and Traefik
neither the password nor its length leaks through timing. 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.
The app is served on a public hostname behind Traefik, which terminates TLS, Two things make that safe, and both must hold:
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 - **`AUTH` names the Authelia middleware** on the router. It is the whole of
address, meaning it arrived through the proxy. A client connecting directly the app's access control. Traefik takes a router out of service when its
could otherwise forge a new address per attempt and skip the limiter. 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.
**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes `/healthz` returns nothing but the version, so it is safe to bypass in
brute force as a practical route; it does not make a guessable password safe. 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
+1 -1
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Foodster"> role="img" aria-label="Foodster">
<!-- Source for the home-screen PNGs; `make icons` rasterises it. <!-- Source for the home-screen PNGs; the README says how to rasterise it.
Unlike favicon.svg this one is opaque and fixed-colour: a home screen Unlike favicon.svg this one is opaque and fixed-colour: a home screen
icon cannot be transparent and must not change with the system theme. icon cannot be transparent and must not change with the system theme.
The bowl sits inside the central 80% so Android can mask it to any The bowl sits inside the central 80% so Android can mask it to any

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 927 B

+49
View File
@@ -131,6 +131,55 @@ func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
} }
} }
func TestTahteetIsLoggableButNotFood(t *testing.T) {
h := seeded(t)
// The migration creates it; nobody adds it.
special, err := listSpecial(h.db, "")
if err != nil {
t.Fatalf("listSpecial: %v", err)
}
if len(special) != 1 || special[0].Name != "Tähteet" {
t.Fatalf("special = %+v, want exactly Tähteet", special)
}
// It must not turn up among the dishes: not on the board's categories,
// not in the catalog, and not in whatever the suggester later draws from.
dishes, err := listDishes(h.db, "")
if err != nil {
t.Fatalf("listDishes: %v", err)
}
for _, d := range dishes {
if d.Name == "Tähteet" {
t.Fatal("Tähteet appears among the dishes")
}
}
// It carries no category at all, which is why it cannot be an ordinary
// dish: those are required to have one.
if len(special[0].Categories) != 0 {
t.Errorf("categories = %v, want none", special[0].Categories)
}
// And it gets its own mark: no categories is not the same as several, so
// it must not fall through to the mixed Sekalaiset one.
if got := special[0].CategoryKey(); got != "tahteet" {
t.Errorf("CategoryKey = %q, want tahteet", got)
}
// Logging it has to work exactly like logging a real meal.
date := day(t, "2026-09-05")
if err := saveEntry(h.db, date, special[0].ID, nil); err != nil {
t.Fatalf("saveEntry: %v", err)
}
entry, err := entryFor(h.db, date)
if err != nil || entry == nil {
t.Fatalf("entryFor: %v, %v", entry, err)
}
if entry.Main.Name != "Tähteet" {
t.Errorf("logged %q, want Tähteet", entry.Main.Name)
}
}
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) { func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
h := seeded(t) h := seeded(t)
id := h.sideNamed(t, "Riisi") id := h.sideNamed(t, "Riisi")
+191 -42
View File
@@ -60,12 +60,19 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
// there is nothing to write down for a dinner that has not happened, and a // there is nothing to write down for a dinner that has not happened, and a
// stray entry dated next year would sit at the top of the history forever. // stray entry dated next year would sit at the top of the history forever.
// Every read and write goes through here, so the clamp covers them all. // Every read and write goes through here, so the clamp covers them all.
//
// The past is clamped too, at maxHistoryDays. The day list runs unbroken from
// today down to the selected day, so a picker set to 1994 would ask for eleven
// thousand rows. Same ceiling ?paivat= already has.
func (a *app) date(r *http.Request) time.Time { func (a *app) date(r *http.Request) time.Time {
now := today(a.loc) now := today(a.loc)
if raw := r.FormValue("pvm"); raw != "" { if raw := r.FormValue("pvm"); raw != "" {
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil { if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
if d.After(now) { switch floor := now.AddDate(0, 0, -maxHistoryDays+1); {
case d.After(now):
return now return now
case d.Before(floor):
return floor
} }
return d return d
} }
@@ -86,6 +93,7 @@ type logView struct {
ShowBoard bool ShowBoard bool
Dishes []Dish // flat, only to know whether anything matched Dishes []Dish // flat, only to know whether anything matched
Groups []DishGroup // what the board actually renders Groups []DishGroup // what the board actually renders
Special []Dish // Tähteet and the like: loggable, but not food
Sides []Side Sides []Side
New mainForm // inline "add the dish you were looking for" New mainForm // inline "add the dish you were looking for"
History HistoryPage History HistoryPage
@@ -97,17 +105,57 @@ type logView struct {
Confirming bool Confirming bool
} }
// logOptions is what the Kirjaa screen is being asked to show. Pulled out of
// the request for a page load or a patch, and set directly after a write,
// where the answer is simply "that day, nothing else open".
type logOptions struct {
Date time.Time
Dish string // ?ruoka=, opening the sides step
Changing bool // ?muuta=, swapping the dish on a logged day
Confirming bool // ?poista=, asking before deleting the entry
Search string
}
func (a *app) logOptionsFrom(r *http.Request) logOptions {
q := r.URL.Query()
return logOptions{
Date: a.date(r),
Dish: q.Get("ruoka"),
Changing: q.Get("muuta") != "",
Confirming: q.Get("poista") != "",
Search: strings.TrimSpace(q.Get("haku")),
}
}
func (a *app) index(w http.ResponseWriter, r *http.Request) { func (a *app) index(w http.ResponseWriter, r *http.Request) {
date := a.date(r) render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(r))))
}
// day patches the list in place. Every link in it calls here rather than
// loading a page, so opening a day leaves the scroll position alone.
func (a *app) day(w http.ResponseWriter, r *http.Request) {
fragment(w, r, dayList(a.buildLog(r, a.logOptionsFrom(r))))
}
// finishDay answers a write: a patch for Datastar, a redirect otherwise.
func (a *app) finishDay(w http.ResponseWriter, r *http.Request, date time.Time) {
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, logOptions{Date: date})))
return
}
a.redirectToDay(w, r, date)
}
func (a *app) buildLog(r *http.Request, o logOptions) logView {
date := o.Date
v := logView{ v := logView{
Date: date, Date: date,
Today: today(a.loc), Today: today(a.loc),
Search: strings.TrimSpace(r.URL.Query().Get("haku")), Search: o.Search,
Checked: map[int64]bool{}, Checked: map[int64]bool{},
Confirming: o.Confirming,
} }
v.Confirming = r.URL.Query().Get("poista") != ""
entry, err := entryFor(a.db, date) entry, err := entryFor(a.db, date)
if err != nil { if err != nil {
log.Printf("entry for %s: %v", date.Format(dateLayout), err) log.Printf("entry for %s: %v", date.Format(dateLayout), err)
@@ -117,8 +165,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
// ?ruoka= opens the sides step for that dish. When it is the dish already // ?ruoka= opens the sides step for that dish. When it is the dish already
// logged, the existing sides come back ticked, which makes editing an // logged, the existing sides come back ticked, which makes editing an
// entry the same screen as creating one. // entry the same screen as creating one.
if raw := r.URL.Query().Get("ruoka"); raw != "" { if o.Dish != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil { if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil {
if dish, err := dishByID(a.db, id); err == nil { if dish, err := dishByID(a.db, id); err == nil {
v.Chosen = dish v.Chosen = dish
if entry != nil && entry.Main.ID == id { if entry != nil && entry.Main.ID == id {
@@ -133,7 +181,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
// The board shows when there is nothing logged yet, or when the entry is // The board shows when there is nothing logged yet, or when the entry is
// being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here, // being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
// so swapping the dish and picking one for the first time are one path. // so swapping the dish and picking one for the first time are one path.
changing := r.URL.Query().Get("muuta") != "" || v.Search != "" changing := o.Changing || v.Search != ""
if v.Chosen == nil && (v.Entry == nil || changing) { if v.Chosen == nil && (v.Entry == nil || changing) {
v.ShowBoard = true v.ShowBoard = true
if v.Dishes, err = listDishes(a.db, v.Search); err != nil { if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
@@ -142,6 +190,9 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
// listDishes already orders by frequency then name, so grouping keeps // listDishes already orders by frequency then name, so grouping keeps
// the favourites at the top of each category. // the favourites at the top of each category.
v.Groups = groupDishes(v.Dishes) v.Groups = groupDishes(v.Dishes)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("list special: %v", err)
}
// Seed the inline add form with whatever was searched for, so a miss // Seed the inline add form with whatever was searched for, so a miss
// turns straight into "add it" without retyping. // turns straight into "add it" without retyping.
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
@@ -152,13 +203,31 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
} }
} }
a.loadDays(r, &v)
return v
}
// loadDays fills the day list. The selected day expands inside it rather than
// in a panel above it, so choosing a day from the list does not reorder the
// list underneath the tap.
func (a *app) loadDays(r *http.Request, v *logView) {
v.HistoryDays = historyWindow(r) v.HistoryDays = historyWindow(r)
v.HistoryMore = v.HistoryDays + historyDays v.HistoryMore = v.HistoryDays + historyDays
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
// The window has to reach the selected day, or it would have nowhere to
// expand. history() takes it as a floor rather than the caller inflating
// the day count, because the window also truncates at the first entry ever
// logged — and a day older than that still has to be loggable.
page, err := history(a.db, a.loc, v.Today, v.HistoryDays, v.Date)
if err != nil {
log.Printf("history: %v", err) log.Printf("history: %v", err)
} }
// Nothing logged ever: the selected day is still the one being worked on,
render(w, r, logPage(v)) // so it needs a row of its own to open in.
if len(page.Rows) == 0 {
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
}
v.History = page
} }
// searchSignals is what Datastar sends back: for a GET it JSON-encodes the // searchSignals is what Datastar sends back: for a GET it JSON-encodes the
@@ -191,6 +260,50 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
} }
} }
// isDatastar reports whether the request came from the client library, which
// tags its own. Everything below keeps working without JavaScript: the same
// handlers redirect instead of patching when the header is absent.
func isDatastar(r *http.Request) bool {
return r.Header.Get("Datastar-Request") != ""
}
// patchElements sends one Datastar event carrying several elements, each
// matched to the page by its id. A text/html response can only replace one
// element, and the catalog has to move its list and its forms together —
// opening an edit form also has to un-highlight whatever was open before.
//
// ponytail: about twenty lines instead of the SDK, which brought four modules
// for an SSE generator we would otherwise never call.
func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
var out strings.Builder
out.WriteString("event: datastar-patch-elements\n")
for _, c := range components {
var html strings.Builder
if err := c.Render(r.Context(), &html); err != nil {
log.Printf("patch %s: %v", r.URL.Path, err)
return
}
// One `data: elements` line per line of HTML, as the protocol wants.
for _, line := range strings.Split(html.String(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
out.WriteString("data: elements ")
out.WriteString(line)
out.WriteString("\n")
}
}
out.WriteString("\n")
io.WriteString(w, out.String())
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// searchBoard re-renders the dish board as the search box is typed into. // searchBoard re-renders the dish board as the search box is typed into.
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) { func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
var signals searchSignals var signals searchSignals
@@ -210,6 +323,9 @@ func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
} }
v.Dishes = dishes v.Dishes = dishes
v.Groups = groupDishes(dishes) v.Groups = groupDishes(dishes)
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
log.Printf("search special: %v", err)
}
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true} v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
fragment(w, r, boardList(v)) fragment(w, r, boardList(v))
@@ -273,6 +389,12 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
log.Printf("quick add: %v", err) log.Printf("quick add: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
// Created: straight on to its sides step.
opts := logOptions{Date: date, Dish: strconv.FormatInt(id, 10)}
if isDatastar(r) {
fragment(w, r, dayList(a.buildLog(r, opts)))
return
}
a.redirectToPick(w, r, date, id) a.redirectToPick(w, r, date, id)
return return
} }
@@ -280,23 +402,11 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
// Rejected: back to the board with the form filled in and the search // Rejected: back to the board with the form filled in and the search
// still narrowed, so the add card stays on screen. // still narrowed, so the add card stays on screen.
v := logView{ v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
Date: date, v.New = form
Today: today(a.loc), if isDatastar(r) {
Search: form.Name, fragment(w, r, dayList(v))
Checked: map[int64]bool{}, return
New: form,
ShowBoard: true,
}
var err error
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
log.Printf("list dishes: %v", err)
}
v.Groups = groupDishes(v.Dishes)
v.HistoryDays = historyWindow(r)
v.HistoryMore = v.HistoryDays + historyDays
if v.History, err = history(a.db, a.loc, today(a.loc), v.HistoryDays); err != nil {
log.Printf("history: %v", err)
} }
render(w, r, logPage(v)) render(w, r, logPage(v))
} }
@@ -332,7 +442,7 @@ func (a *app) save(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError) http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
return return
} }
a.redirectToDay(w, r, date) a.finishDay(w, r, date)
} }
func (a *app) delete(w http.ResponseWriter, r *http.Request) { func (a *app) delete(w http.ResponseWriter, r *http.Request) {
@@ -342,15 +452,12 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return return
} }
a.redirectToDay(w, r, date) a.finishDay(w, r, date)
} }
// redirectToDay is the no-JavaScript path back after a write.
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) { func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
target := "/" http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
if !date.Equal(today(a.loc)) {
target += "?pvm=" + date.Format(dateLayout)
}
http.Redirect(w, r, target, http.StatusSeeOther)
} }
// mainForm and sideForm carry what the user typed, so a rejected submission // mainForm and sideForm carry what the user typed, so a rejected submission
@@ -384,7 +491,20 @@ type catalogView struct {
DeleteKind string DeleteKind string
} }
// catalog renders the whole page. show patches the same state in place, so
// nothing navigates: both build the view the same way.
func (a *app) catalog(w http.ResponseWriter, r *http.Request) { func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
a.renderCatalog(w, r, a.catalogState(r))
}
// show is what every catalog link actually calls. It patches the list and both
// forms rather than loading a page, so opening an edit form or asking to
// delete a row leaves the scroll position exactly where it was.
func (a *app) show(w http.ResponseWriter, r *http.Request) {
a.patchCatalog(w, r, a.catalogState(r))
}
func (a *app) catalogState(r *http.Request) catalogView {
v := catalogView{ v := catalogView{
Main: mainForm{Categories: map[string]bool{}, HasSides: true}, Main: mainForm{Categories: map[string]bool{}, HasSides: true},
Search: strings.TrimSpace(r.URL.Query().Get("haku")), Search: strings.TrimSpace(r.URL.Query().Get("haku")),
@@ -420,10 +540,11 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
} }
} }
a.renderCatalog(w, r, v) return v
} }
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { // fillCatalog loads the lists into a view built from the request.
func (a *app) fillCatalog(v *catalogView) {
if v.Main.Categories == nil { if v.Main.Categories == nil {
v.Main.Categories = map[string]bool{} v.Main.Categories = map[string]bool{}
} }
@@ -439,9 +560,20 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
if v.Sides, err = listSides(a.db, v.Search); err != nil { if v.Sides, err = listSides(a.db, v.Search); err != nil {
log.Printf("list sides: %v", err) log.Printf("list sides: %v", err)
} }
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
render(w, r, catalogPage(v)) render(w, r, catalogPage(v))
} }
// patchCatalog swaps the list and both forms in one event. They move together:
// opening an edit form also has to clear whatever delete was being confirmed.
func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
a.fillCatalog(&v)
patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side))
}
// saveMain adds or updates a main dish. A rejected form is re-rendered with // saveMain adds or updates a main dish. A rejected form is re-rendered with
// the values still in it; a good one redirects, so refresh cannot re-submit. // the values still in it; a good one redirects, so refresh cannot re-submit.
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
@@ -482,11 +614,28 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
log.Printf("save main: %v", err) log.Printf("save main: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
http.Redirect(w, r, "/ruoat", http.StatusSeeOther) // Saved: hand back a blank form so it collapses, and a list with
// the dish in it.
a.finishCatalog(w, r, catalogView{})
return return
} }
} }
a.renderCatalog(w, r, catalogView{Main: form}) a.finishCatalog(w, r, catalogView{Main: form})
}
// finishCatalog answers a catalog write: a patch for Datastar, a redirect for
// a plain form post. Without the redirect, submitting with JavaScript off
// would leave the browser sitting on a POST it could not reload.
func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
if isDatastar(r) {
a.patchCatalog(w, r, v)
return
}
if v.Main.Err != "" || v.Side.Err != "" {
a.renderCatalog(w, r, v)
return
}
http.Redirect(w, r, "/ruuat", http.StatusSeeOther)
} }
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
@@ -511,11 +660,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
log.Printf("save side: %v", err) log.Printf("save side: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
http.Redirect(w, r, "/ruoat", http.StatusSeeOther) a.finishCatalog(w, r, catalogView{})
return return
} }
} }
a.renderCatalog(w, r, catalogView{Side: form}) a.finishCatalog(w, r, catalogView{Side: form})
} }
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6). // deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
@@ -539,7 +688,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/ruoat", http.StatusSeeOther) a.finishCatalog(w, r, catalogView{})
} }
// importDishes takes a bundle either pasted into the textarea or uploaded as a // importDishes takes a bundle either pasted into the textarea or uploaded as a
+41 -58
View File
@@ -7,8 +7,6 @@ package main
import ( import (
"cmp" "cmp"
"context" "context"
"crypto/sha256"
"crypto/subtle"
"database/sql" "database/sql"
"embed" "embed"
"errors" "errors"
@@ -32,9 +30,24 @@ import (
//go:embed static //go:embed static
var staticFS embed.FS var staticFS embed.FS
// version is replaced at build time with the CalVer tag (see `make image`). // version is replaced at build time with the CalVer tag by the release workflow.
var version = "dev" 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 ( const (
listenAddr = ":8080" listenAddr = ":8080"
defaultTZ = "Europe/Helsinki" defaultTZ = "Europe/Helsinki"
@@ -55,22 +68,17 @@ func run() error {
"import a JSON dish bundle (PRD §7.3 shape) and exit") "import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse() flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB)) db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB))
if err != nil { if err != nil {
return err return err
} }
defer db.Close() defer db.Close()
// Importing is an offline chore: no password needed, no server started. // Importing is an offline chore: no server started, nothing to serve.
if *importPath != "" { if *importPath != "" {
return runImport(db, *importPath) return runImport(db, *importPath)
} }
password := os.Getenv("FOODSTER_PASSWORD")
if password == "" {
return errors.New("FOODSTER_PASSWORD is not set")
}
// Fail rather than fall back to UTC: a silently wrong zone shifts logged // Fail rather than fall back to UTC: a silently wrong zone shifts logged
// dinners onto the wrong calendar day, which is invisible until the // dinners onto the wrong calendar day, which is invisible until the
// history is already corrupt. // history is already corrupt.
@@ -79,14 +87,21 @@ func run() error {
return fmt.Errorf("TZ: %w", err) return fmt.Errorf("TZ: %w", err)
} }
// The container always publishes :8080; FOODSTER_ADDR exists so tests and setEnvTag(os.Getenv("ENV"))
// a second local instance can pick another port.
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
// 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)
// WriteTimeout and IdleTimeout matter more than they look: the pool holds
// exactly one database connection, so a reader stalling on a long history
// response blocks every other request behind it.
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: routes(db, loc, password), Handler: routes(db, loc),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
} }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -146,7 +161,11 @@ func openDB(path string) (*sql.DB, error) {
return db, nil return db, nil
} }
func routes(db *sql.DB, loc *time.Location, password string) http.Handler { // routes serves the app unauthenticated. Access control is the reverse proxy's
// job: Traefik forwards every request to Authelia before it reaches here, so a
// second password in front of it only ever meant two prompts for one door. The
// container publishes no ports, so nothing but the proxy can reach it.
func routes(db *sql.DB, loc *time.Location) http.Handler {
// Go's mime table has no entry for .webmanifest, and a manifest served as // Go's mime table has no entry for .webmanifest, and a manifest served as
// octet-stream is ignored by the browser. // octet-stream is ignored by the browser.
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json") _ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
@@ -159,59 +178,23 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("POST /kirjaa", a.save) mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd) mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("GET /etsi", a.searchBoard) mux.HandleFunc("GET /etsi", a.searchBoard)
mux.HandleFunc("GET /paiva", a.day)
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
mux.HandleFunc("GET /ruuat/nayta", a.show)
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish) mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes) mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it. // /healthz is an ordinary route now that the app has no auth of its own.
root := http.NewServeMux() // It reveals only the version, so an Authelia bypass rule for it is safe if
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { // a monitor needs to poll from outside the container network.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, version) fmt.Fprintln(w, version)
}) })
root.Handle("/", auth(password, mux)) return mux
return root
}
// auth gates everything behind one shared household password. There are no
// accounts, so the username is ignored (PRD §9).
func auth(password string, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(password))
guesses := newThrottle()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, given, ok := r.BasicAuth()
// A request with no Authorization header is the normal browser
// handshake, not a guess: every session opens with one. Challenge it
// without spending the address's allowance.
if !ok {
challenge(w)
return
}
// Hashing first keeps the comparison a fixed length, so neither the
// password nor its length leaks through timing.
got := sha256.Sum256([]byte(given))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
if !guesses.allow(clientIP(r)) {
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
return
}
challenge(w)
return
}
next.ServeHTTP(w, r)
})
}
func challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
} }
// today is the current calendar day in the configured location, truncated to // today is the current calendar day in the configured location, truncated to
+40 -55
View File
@@ -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) { func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db") db, err := openDB(t.TempDir() + "/test.db")
if err != nil { if err != nil {
@@ -246,14 +269,16 @@ func TestMealLogOneEntryPerDate(t *testing.T) {
} }
defer db.Close() defer db.Close()
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil { // Ids well clear of anything the migrations create.
if _, err := db.Exec(
`INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
} }
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil { if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); err != nil {
t.Fatalf("first entry: %v", err) t.Fatalf("first entry: %v", err)
} }
// PRD §6: a second dinner for the same day must be refused. // PRD §6: a second dinner for the same day must be refused.
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil { if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 102)`); err == nil {
t.Error("second entry for the same date was accepted, want a unique violation") t.Error("second entry for the same date was accepted, want a unique violation")
} }
} }
@@ -265,79 +290,39 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
} }
defer db.Close() defer db.Close()
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil { if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (101, 'Kanacurry')`); err != nil {
t.Fatalf("first insert: %v", err) t.Fatalf("first insert: %v", err)
} }
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil { if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err == nil {
t.Error("case-variant duplicate was accepted, want a unique violation") t.Error("case-variant duplicate was accepted, want a unique violation")
} }
// Soft-deleting the original frees the name again (PRD §7.3). // Soft-deleting the original frees the name again (PRD §7.3).
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil { if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 101`); err != nil {
t.Fatalf("soft delete: %v", err) t.Fatalf("soft delete: %v", err)
} }
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil { if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, 'kanacurry')`); err != nil {
t.Errorf("name still blocked after soft delete: %v", err) t.Errorf("name still blocked after soft delete: %v", err)
} }
} }
func TestAuth(t *testing.T) { // The app carries no authentication of its own — Authelia in front of Traefik
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // does that — so the only thing left to assert is that every route answers
w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler // without credentials. A 401 from here would mean auth crept back in.
})) func TestRoutesNeedNoCredentials(t *testing.T) {
cases := []struct {
name string
user string
pass string
withAuth bool
want int
}{
{"correct password", "", "hunter2", true, http.StatusTeapot},
{"username is ignored", "anyone", "hunter2", true, http.StatusTeapot},
{"wrong password", "", "wrong", true, http.StatusUnauthorized},
{"empty password", "", "", true, http.StatusUnauthorized},
{"no credentials", "", "", false, http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if c.withAuth {
r.SetBasicAuth(c.user, c.pass)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != c.want {
t.Errorf("status = %d, want %d", w.Code, c.want)
}
if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
t.Error("401 without a WWW-Authenticate header; the browser will not prompt")
}
})
}
}
func TestHealthzSkipsAuth(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db") db, err := openDB(t.TempDir() + "/test.db")
if err != nil { if err != nil {
t.Fatalf("openDB: %v", err) t.Fatalf("openDB: %v", err)
} }
defer db.Close() defer db.Close()
h := routes(db, time.UTC, "hunter2") h := routes(db, time.UTC)
for _, path := range []string{"/healthz", "/", "/ruuat"} {
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("/healthz without credentials = %d, want 200", w.Code) t.Errorf("GET %s = %d, want 200", path, w.Code)
} }
// Everything else must still be gated.
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
if w.Code != http.StatusUnauthorized {
t.Errorf("/ without credentials = %d, want 401", w.Code)
} }
} }
+19
View File
@@ -0,0 +1,19 @@
-- Tähteet: leftovers.
--
-- Not a dish. It exists so a day can be recorded as "we ate what was already
-- there" without inventing a meal that was never cooked. It has no category,
-- it is not something the household adds or edits, and the stage 2 suggester
-- must never propose it (PRD §8).
--
-- Modelled as a flagged row in main_dishes rather than a nullable
-- main_dish_id on meal_log: the log keeps one shape, and every foreign key
-- and join carries on working untouched.
ALTER TABLE main_dishes
ADD COLUMN special INTEGER NOT NULL DEFAULT 0 CHECK (special IN (0, 1));
-- OR IGNORE in case a household already typed a dish by this name: the unique
-- index on lower(name) would otherwise fail the migration. Their row stays as
-- an ordinary dish, which is wrong but harmless and fixable by hand.
INSERT OR IGNORE INTO main_dishes (name, has_sides, special)
VALUES ('Tähteet', 0, 1);
+75 -4
View File
@@ -134,6 +134,8 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
.c-kana { color: var(--kana); } .c-kana { color: var(--kana); }
.c-kala { color: var(--kala); } .c-kala { color: var(--kala); }
.c-kasvis { color: var(--kasvis); } .c-kasvis { color: var(--kasvis); }
/* Not a category, so not a category colour. */
.c-tahteet { color: var(--muted); }
/* Day switcher */ /* Day switcher */
.dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; } .dayseg { display: flex; gap: 6px; margin-top: 11px; flex-wrap: wrap; }
@@ -208,6 +210,20 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
font-size: 10px; font-size: 10px;
color: var(--muted); color: var(--muted);
} }
/* Not food: set apart from the categories, and deliberately quiet. */
.special {
margin-top: 22px;
padding-top: 16px;
border-top: 1px dashed var(--line);
}
.pill.plain {
font-size: 16px;
padding: 12px 16px;
background: var(--sunk);
color: var(--muted);
font-weight: 600;
}
.pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; } .pill.xl { font-size: 22px; padding: 14px 18px; flex: 1 1 100%; }
.pill.lg { font-size: 18px; padding: 12px 16px; } .pill.lg { font-size: 18px; padding: 12px 16px; }
.pill.md { font-size: 15.5px; padding: 11px 14px; } .pill.md { font-size: 15.5px; padding: 11px 14px; }
@@ -310,8 +326,26 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
/* History sits under the logger on the same page, so whole rows are links. */ /* The day list is the page; rows are links and the selected one expands. */
.history { margin-top: 8px; } .history { margin-top: 4px; }
/* Marked with a bar down the side, not rules above and below: the rows either
side already draw a bottom border, so a horizontal rule here doubled up.
scroll-margin keeps the anchor off the viewport edge. */
.open {
scroll-margin-top: 12px;
margin: 8px 0 18px;
padding: 10px 0 4px 13px;
border-left: 3px solid var(--accent);
}
.openday {
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
.entry, .gapline { .entry, .gapline {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -423,15 +457,52 @@ html[data-theme="light"] .themetoggle .i-moon { display: none; }
cursor: pointer; cursor: pointer;
} }
/* Catalog rows */ /* Catalog structure: Pääruuat and Lisukkeet are the two halves of the
catalog, the categories are subdivisions of the first. Two levels, so they
must not look alike. */
.section + .section { margin-top: 34px; }
.sectiontitle {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 4px;
padding-bottom: 8px;
border-bottom: 2px solid var(--ink);
font-size: 18px;
font-weight: 700;
letter-spacing: -0.03em;
}
.sectiontitle .count {
padding: 2px 8px;
border-radius: 999px;
background: var(--sunk);
color: var(--muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
}
.sechead { .sechead {
margin: 26px 0 6px; margin: 20px 0 2px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.09em; letter-spacing: 0.09em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
/* Collapsed add/edit forms, so the page opens on the catalog. */
.addform > summary {
cursor: pointer;
font-weight: 600;
font-size: 15px;
min-height: 24px;
}
.addform[open] > summary {
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
+123 -11
View File
@@ -28,13 +28,24 @@ type Dish struct {
TimesEaten int TimesEaten int
} }
// CategoryKey is the class suffix for the colour dot. A dish covering several // Rows flagged `special` in the database — Tähteet — are loggable but are not
// categories (tortillas, build-your-own pizza) gets the mixed marker. // food. They carry no category, never appear in the catalog, and PRD §8
// excludes them from the suggester: cooldown, coverage and weighting all skip
// them. dishByID deliberately does not filter on the flag, because logging one
// has to work like logging anything else.
// CategoryKey picks the mark for a dish. One covering several categories
// (tortillas, build-your-own pizza) gets the mixed one; carrying none at all
// means it is not food, and Tähteet is not a mixture of anything.
func (d Dish) CategoryKey() string { func (d Dish) CategoryKey() string {
if len(d.Categories) == 1 { switch len(d.Categories) {
case 0:
return "tahteet"
case 1:
return categoryFI[d.Categories[0]] return categoryFI[d.Categories[0]]
} default:
return "sek" return "sek"
}
} }
// Size buckets the dish by how often it has been eaten. The board draws // Size buckets the dish by how often it has been eaten. The board draws
@@ -77,6 +88,17 @@ func (e Entry) SidesLabel() string {
// listDishes returns live mains ordered by how often they have been eaten. // listDishes returns live mains ordered by how often they have been eaten.
// An empty search matches everything. // An empty search matches everything.
func listDishes(db *sql.DB, search string) ([]Dish, error) { func listDishes(db *sql.DB, search string) ([]Dish, error) {
return queryDishes(db, search, false)
}
// listSpecial returns the entries that are not food — Tähteet and anything
// like it. They are loggable but never suggested, and never appear in the
// catalog, so they are fetched deliberately rather than by accident.
func listSpecial(db *sql.DB, search string) ([]Dish, error) {
return queryDishes(db, search, true)
}
func queryDishes(db *sql.DB, search string, special bool) ([]Dish, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT m.id, m.name, m.has_sides, SELECT m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category) coalesce((SELECT group_concat(c.category)
@@ -85,8 +107,9 @@ func listDishes(db *sql.DB, search string) ([]Dish, error) {
(SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id) (SELECT count(*) FROM meal_log l WHERE l.main_dish_id = m.id)
FROM main_dishes m FROM main_dishes m
WHERE m.deleted_at IS NULL WHERE m.deleted_at IS NULL
AND m.special = ?
AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%') AND (? = '' OR lower(m.name) LIKE '%' || lower(?) || '%')
ORDER BY 5 DESC, m.name`, search, search) ORDER BY 5 DESC, m.name`, special, search, search)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -282,7 +305,8 @@ func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.Exec( if _, err := tx.Exec(
`UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`, `UPDATE main_dishes SET name = ?, has_sides = ?
WHERE id = ? AND deleted_at IS NULL AND special = 0`,
name, hasSides, id, name, hasSides, id,
); err != nil { ); err != nil {
return taken(err) return taken(err)
@@ -322,9 +346,14 @@ func updateSide(db *sql.DB, id int64, name string) error {
// Soft delete: the row stays so historical log entries keep resolving their // Soft delete: the row stays so historical log entries keep resolving their
// names, but it disappears from the catalog and every picker (PRD §6). // names, but it disappears from the catalog and every picker (PRD §6).
//
// `special = 0` here and in updateMain: the catalog never lists Tähteet, so
// the UI cannot reach it, but a stale tab or a hand-made POST could — and
// removing it would take away the row migration 0002 guarantees.
func softDeleteMain(db *sql.DB, id int64) error { func softDeleteMain(db *sql.DB, id int64) error {
_, err := db.Exec( _, err := db.Exec(
`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id) `UPDATE main_dishes SET deleted_at = datetime('now')
WHERE id = ? AND special = 0`, id)
return err return err
} }
@@ -409,7 +438,12 @@ type HistoryPage struct {
// history walks back day by day from a given day, so a day nobody wrote down // history walks back day by day from a given day, so a day nobody wrote down
// shows up as an explicit gap rather than silently missing. It stops at the // shows up as an explicit gap rather than silently missing. It stops at the
// first entry ever recorded — before that there is no history to be missing. // first entry ever recorded — before that there is no history to be missing.
func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryPage, error) { //
// reach names a day that must appear whatever the window says. The log board
// opens inside the selected day's row, so a date picked from before the first
// entry ever logged used to render nothing at all: no row, no board, no way to
// log it. Pass the zero time to ask for the plain window.
func history(db *sql.DB, loc *time.Location, from time.Time, days int, reach time.Time) (HistoryPage, error) {
var first sql.NullString var first sql.NullString
if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil { if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -434,14 +468,92 @@ func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryP
oldest = firstDate oldest = firstDate
page.More = false page.More = false
} }
if !reach.IsZero() && reach.Before(oldest) {
oldest = reach
page.More = firstDate.Before(oldest)
}
page.Next = oldest.AddDate(0, 0, -1) page.Next = oldest.AddDate(0, 0, -1)
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) { entries, err := entriesBetween(db, oldest, from)
entry, err := entryFor(db, d)
if err != nil { if err != nil {
return HistoryPage{}, err return HistoryPage{}, err
} }
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry}) for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
row := HistoryRow{Date: d}
if e := entries[d.Format(dateLayout)]; e != nil {
e.Date = d
row.Entry = e
}
page.Rows = append(page.Rows, row)
} }
return page, nil return page, nil
} }
// entriesBetween loads every logged day in the inclusive range, keyed by
// stored date string, in two queries rather than two per day. The widest
// window a URL can ask for is five years, which day-at-a-time made 3,600 round
// trips through a pool of exactly one connection.
func entriesBetween(db *sql.DB, from, to time.Time) (map[string]*Entry, error) {
lo, hi := from.Format(dateLayout), to.Format(dateLayout)
rows, err := db.Query(`
SELECT l.id, l.date, m.id, m.name, m.has_sides,
coalesce((SELECT group_concat(c.category)
FROM main_dish_categories c
WHERE c.main_dish_id = m.id), '')
FROM meal_log l
JOIN main_dishes m ON m.id = l.main_dish_id
WHERE l.date BETWEEN ? AND ?`, lo, hi)
if err != nil {
return nil, err
}
defer rows.Close()
byDate := map[string]*Entry{}
byLog := map[int64]*Entry{}
for rows.Next() {
var logID int64
var date, cats string
var e Entry
if err := rows.Scan(
&logID, &date, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats,
); err != nil {
return nil, err
}
if cats != "" {
e.Main.Categories = strings.Split(cats, ",")
}
byDate[date] = &e
byLog[logID] = &e
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(byLog) == 0 {
return byDate, nil
}
sides, err := db.Query(`
SELECT ls.meal_log_id, s.id, s.name
FROM meal_log_sides ls
JOIN side_dishes s ON s.id = ls.side_dish_id
JOIN meal_log l ON l.id = ls.meal_log_id
WHERE l.date BETWEEN ? AND ?
ORDER BY s.name`, lo, hi)
if err != nil {
return nil, err
}
defer sides.Close()
for sides.Next() {
var logID int64
var s Side
if err := sides.Scan(&logID, &s.ID, &s.Name); err != nil {
return nil, err
}
if e := byLog[logID]; e != nil {
e.Sides = append(e.Sides, s)
}
}
return byDate, sides.Err()
}
+65 -4
View File
@@ -235,7 +235,7 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
t.Fatalf("save -3: %v", err) t.Fatalf("save -3: %v", err)
} }
page, err := history(h.db, loc, now, 60) page, err := history(h.db, loc, now, 60, time.Time{})
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
@@ -271,7 +271,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
t.Fatalf("save -9: %v", err) t.Fatalf("save -9: %v", err)
} }
first, err := history(h.db, loc, now, 5) first, err := history(h.db, loc, now, 5, time.Time{})
if err != nil { if err != nil {
t.Fatalf("first window: %v", err) t.Fatalf("first window: %v", err)
} }
@@ -286,7 +286,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
} }
// The windows must meet exactly: no day repeated, none skipped. // The windows must meet exactly: no day repeated, none skipped.
second, err := history(h.db, loc, first.Next, 5) second, err := history(h.db, loc, first.Next, 5, time.Time{})
if err != nil { if err != nil {
t.Fatalf("second window: %v", err) t.Fatalf("second window: %v", err)
} }
@@ -305,7 +305,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
func TestHistoryEmptyWithoutEntries(t *testing.T) { func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(t) h := seeded(t)
page, err := history(h.db, time.UTC, today(time.UTC), 60) page, err := history(h.db, time.UTC, today(time.UTC), 60, time.Time{})
if err != nil { if err != nil {
t.Fatalf("history: %v", err) t.Fatalf("history: %v", err)
} }
@@ -316,3 +316,64 @@ func TestHistoryEmptyWithoutEntries(t *testing.T) {
t.Error("More is set although there is no history at all") t.Error("More is set although there is no history at all")
} }
} }
// A date picked from before the first entry ever logged used to fall outside
// the window entirely: no row, so the log board had nothing to open in and the
// day could not be filled in at all.
func TestHistoryReachesDaysOlderThanTheFirstEntry(t *testing.T) {
h := seeded(t)
loc := time.UTC
now := today(loc)
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
t.Fatalf("save today: %v", err)
}
want := now.AddDate(0, 0, -100)
page, err := history(h.db, loc, now, 30, want)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(page.Rows) != 101 {
t.Fatalf("%d rows, want 101 (today back to the selected day)", len(page.Rows))
}
last := page.Rows[len(page.Rows)-1]
if !last.Date.Equal(want) {
t.Errorf("last row is %s, want the selected %s",
last.Date.Format(dateLayout), want.Format(dateLayout))
}
if page.More {
t.Error("More is set although the window reached past the oldest entry")
}
}
// The sides of every day come back in one query now; each still has to land on
// its own day.
func TestHistoryKeepsSidesWithTheirOwnDay(t *testing.T) {
h := seeded(t)
loc := time.UTC
now := today(loc)
muusi := h.sideNamed(t, "Perunamuusi")
riisi := h.sideNamed(t, "Riisi")
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), []int64{muusi}); err != nil {
t.Fatalf("save today: %v", err)
}
if err := saveEntry(h.db, now.AddDate(0, 0, -1), h.mainNamed(t, "Lihapullat"), []int64{riisi}); err != nil {
t.Fatalf("save -1: %v", err)
}
page, err := history(h.db, loc, now, 30, time.Time{})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(page.Rows) != 2 {
t.Fatalf("%d rows, want 2", len(page.Rows))
}
for i, want := range []string{"Perunamuusi", "Riisi"} {
got := page.Rows[i].Entry
if got == nil || len(got.Sides) != 1 || got.Sides[0].Name != want {
t.Errorf("row %d sides = %+v, want just %s", i, got.Sides, want)
}
}
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// The app is reachable from the internet, so a shared password needs more
// than a sleep in front of it. These allow a family fumbling the password a
// handful of quick retries, then roughly six a minute — useless for guessing,
// unnoticeable to anyone who knows it.
//
// This buys time; it is not the defence. A strong password is.
const (
guessBurst = 5
guessInterval = 10 * time.Second
// Bounds on the per-IP table, so a spray across many addresses cannot
// grow it without limit.
throttleMaxEntries = 4096
throttleIdle = 15 * time.Minute
)
type visitor struct {
limiter *rate.Limiter
seen time.Time
}
// throttle rate-limits failed password attempts per client address.
//
// ponytail: one mutex over one map. At household traffic this will never be
// contended; shard it if that ever stops being true.
type throttle struct {
mu sync.Mutex
visitors map[string]*visitor
}
func newThrottle() *throttle {
return &throttle{visitors: make(map[string]*visitor)}
}
// allow reports whether another wrong guess from this address is permitted.
func (t *throttle) allow(ip string) bool {
now := time.Now()
t.mu.Lock()
defer t.mu.Unlock()
if len(t.visitors) >= throttleMaxEntries {
t.pruneLocked(now)
}
v := t.visitors[ip]
if v == nil {
v = &visitor{limiter: rate.NewLimiter(rate.Every(guessInterval), guessBurst)}
t.visitors[ip] = v
}
v.seen = now
return v.limiter.Allow()
}
func (t *throttle) pruneLocked(now time.Time) {
for ip, v := range t.visitors {
if now.Sub(v.seen) > throttleIdle {
delete(t.visitors, ip)
}
}
// Still full of live entries: a spray is in progress. Drop the lot rather
// than grow without bound. Everyone gets a fresh allowance, which is the
// safe direction to fail — the password is still required.
if len(t.visitors) >= throttleMaxEntries {
clear(t.visitors)
}
}
// clientIP resolves the address to rate-limit against.
//
// X-Forwarded-For is only believed when the connection itself came from a
// private address, meaning it arrived through the reverse proxy on the
// container network. A client connecting directly could otherwise forge a
// fresh address on every attempt and walk straight past the limiter.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) {
return host
}
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded == "" {
return host
}
// The nearest proxy appends the address it saw, so the last entry is the
// trustworthy one; anything before it was supplied by the client.
parts := strings.Split(forwarded, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(last) == nil {
return host
}
return last
}
-110
View File
@@ -1,110 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestThrottleBlocksRepeatedGuesses(t *testing.T) {
th := newThrottle()
for i := 0; i < guessBurst; i++ {
if !th.allow("198.51.100.7") {
t.Fatalf("guess %d refused inside the burst", i+1)
}
}
if th.allow("198.51.100.7") {
t.Error("guess allowed past the burst")
}
// A different address has its own allowance.
if !th.allow("198.51.100.8") {
t.Error("a second address was blocked by the first one's guesses")
}
}
func TestClientIPIgnoresForwardedHeaderFromDirectClients(t *testing.T) {
// Connecting straight from the internet: X-Forwarded-For is attacker
// input, so a forged value must not create a fresh rate-limit bucket.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.9:44321"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP = %q, want the real peer 203.0.113.9", got)
}
}
func TestClientIPTakesLastForwardedEntryBehindProxy(t *testing.T) {
// Arriving through Traefik on the container network. The proxy appends
// the address it saw, so the last entry is the trustworthy one and the
// forged entry in front of it must be ignored.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.4:53000"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.22")
if got := clientIP(r); got != "198.51.100.22" {
t.Errorf("clientIP = %q, want 198.51.100.22", got)
}
}
func TestClientIPFallsBackWhenNoForwardedHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.4:53000"
if got := clientIP(r); got != "172.18.0.4" {
t.Errorf("clientIP = %q, want 172.18.0.4", got)
}
}
func TestAuthRateLimitsWrongPasswords(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
send := func(pass string) int {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.5:40000"
r.SetBasicAuth("", pass)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
for i := 0; i < guessBurst; i++ {
if code := send("wrong"); code != http.StatusUnauthorized {
t.Fatalf("guess %d returned %d, want 401", i+1, code)
}
}
if code := send("wrong"); code != http.StatusTooManyRequests {
t.Errorf("guess past the burst returned %d, want 429", code)
}
}
func TestAuthDoesNotSpendAllowanceOnTheBrowserHandshake(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
// Every session opens with a credential-less request. Charging those
// would lock a family out by simply opening the app a few times.
for i := 0; i < guessBurst*4; i++ {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.6:40000"
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("handshake %d returned %d, want 401", i+1, w.Code)
}
}
// The correct password still works afterwards.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.6:40000"
r.SetBasicAuth("", "hunter2")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusTeapot {
t.Errorf("correct password returned %d, want the wrapped handler", w.Code)
}
}
+222 -69
View File
@@ -43,6 +43,23 @@ func dayURL(base string, d, now time.Time) string {
return base + "?pvm=" + isoDate(d) return base + "?pvm=" + isoDate(d)
} }
// showURL turns a catalog page link into the patch endpoint behind it, so the
// href and the Datastar call never drift apart.
func showURL(pageURL string) string {
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
}
// dayPatch is the endpoint behind every link in the day list. The href beside
// it stays a real page URL for anyone without JavaScript; Datastar calls this
// instead and swaps the list where it stands.
func dayPatch(d time.Time, param string) string {
url := "/paiva?pvm=" + isoDate(d)
if param != "" {
url += "&" + param
}
return url
}
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for // pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
// any day but today. // any day but today.
func pickSeparator(v logView) string { func pickSeparator(v logView) string {
@@ -52,6 +69,16 @@ func pickSeparator(v logView) string {
return "&" return "&"
} }
// stepURL is the page URL for a link inside the open day: the fallback when
// there is no JavaScript to intercept it.
func stepURL(v logView, param string) string {
url := dayURL("/", v.Date, v.Today)
if param != "" {
url += pickSeparator(v) + param
}
return url
}
// jsString renders a Go string as a JavaScript literal, for the data-signals // jsString renders a Go string as a JavaScript literal, for the data-signals
// attribute that seeds the search box. // attribute that seeds the search box.
func jsString(s string) string { func jsString(s string) string {
@@ -81,6 +108,16 @@ func categoryLabels(d Dish) string {
return strings.Join(names, ", ") 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 // countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
// after every number except one. // after every number except one.
func countFI(n int, one, many string) string { func countFI(n int, one, many string) string {
@@ -103,11 +140,12 @@ templ page(title, current string) {
// header rather than butting against it. // header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ title }</title> <title>{ pageTitle(title) }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and <!-- use-credentials: the manifest fetch is anonymous by default, so
would otherwise come back 401 and be ignored. --> behind Authelia it would be redirected to the login page and
the manifest quietly ignored. -->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/> <link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
<link rel="stylesheet" href="/static/app.css"/> <link rel="stylesheet" href="/static/app.css"/>
<!-- Not deferred: it applies the stored theme before first paint. --> <!-- Not deferred: it applies the stored theme before first paint. -->
@@ -183,6 +221,10 @@ templ categoryIcon(key string) {
<span class="cat c-kasvis"> <span class="cat c-kasvis">
@glyphKasvis() @glyphKasvis()
</span> </span>
case "tahteet":
<span class="cat c-tahteet">
@glyphTahteet()
</span>
default: default:
<span class="cat"> <span class="cat">
@glyphSekalaiset() @glyphSekalaiset()
@@ -190,6 +232,15 @@ templ categoryIcon(key string) {
} }
} }
// A lidded tub. Tähteet is not food and not a mixture of categories, so it
// gets neither a category colour nor the quartered mark.
templ glyphTahteet() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
</svg>
}
// A steak, its bone knocked out with fill-rule so the hole is transparent on // A steak, its bone knocked out with fill-rule so the hole is transparent on
// whatever background the icon lands on. // whatever background the icon lands on.
templ glyphLiha() { templ glyphLiha() {
@@ -268,6 +319,30 @@ templ logPage(v logView) {
@daySwitch(v) @daySwitch(v)
</header> </header>
<main class="pad"> <main class="pad">
@dayList(v)
</main>
}
}
// dayList is the whole page: every day back through the window, with the
// selected one expanded where it sits. Opening a day used to swap in a panel
// above the list and drop that day out of it, so the rows below jumped up
// under the tap. Now nothing moves — the row grows.
templ dayList(v logView) {
<section class="history" id="paivat">
for i, row := range v.History.Rows {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Date.Equal(v.Date) {
<div class="open">
<p class="openday">
if row.Date.Equal(v.Today) {
Tänään
} else {
{ longDateFI(row.Date) }
}
</p>
switch { switch {
case v.Chosen != nil: case v.Chosen != nil:
@sidesStep(v) @sidesStep(v)
@@ -276,26 +351,13 @@ templ logPage(v logView) {
default: default:
@loggedCard(v) @loggedCard(v)
} }
@historyList(v) </div>
</main> } else if row.Entry != nil {
} <a
} class="entry"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
// historyList sits under the day being logged: the two were always one thing, data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
// since every row here is a link back into the logger above it. >
templ historyList(v logView) {
<section class="history">
<h3 class="sechead">Aiemmin</h3>
if len(v.History.Rows) == 0 {
<p class="muted small">Ei vielä merkintöjä.</p>
}
for i, row := range v.History.Rows {
if !row.Date.Equal(v.Date) {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<a class="entry" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }>
<time>{ dayLabelFI(row.Date) }</time> <time>{ dayLabelFI(row.Date) }</time>
<div> <div>
<div class="nm"> <div class="nm">
@@ -307,18 +369,22 @@ templ historyList(v logView) {
<span class="chev"></span> <span class="chev"></span>
</a> </a>
} else { } else {
<a class="gapline" href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }> <a
class="gapline"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time> <time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span> <span>Ei merkintää</span>
<span class="act">Merkitse</span> <span class="act">Merkitse</span>
</a> </a>
} }
} }
}
if v.History.More { if v.History.More {
<a <a
class="more" class="more"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) } href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "paivat="+strconv.Itoa(v.HistoryMore)) + "')" }
>Näytä lisää</a> >Näytä lisää</a>
} }
</section> </section>
@@ -385,6 +451,29 @@ templ boardList(v logView) {
} }
</div> </div>
} }
// Tähteet is not food, so it sits apart from the categories rather
// than inside one. Fixed size: it will be among the most-logged
// entries, and it should not tower over the actual cooking.
if len(v.Special) > 0 {
<div class="special">
for _, d := range v.Special {
<a
class="pill plain"
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
</div>
}
// Tähteet always matches an empty search, so the add card keys off the
// real dishes only: otherwise a fresh install would show leftovers and
// no way to add anything.
if len(v.Dishes) == 0 { if len(v.Dishes) == 0 {
@quickAddCard(v) @quickAddCard(v)
} }
@@ -407,7 +496,11 @@ templ quickAddCard(v logView) {
if v.New.Err != "" { if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p> <p class="formerr">{ v.New.Err }</p>
} }
<form method="post" action="/lisaa"> <form
method="post"
action="/lisaa"
data-on:submit__prevent="@post('/lisaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field"> <label class="field">
<span>Nimi</span> <span>Nimi</span>
@@ -438,7 +531,8 @@ templ quickAddCard(v logView) {
templ dishPill(d Dish, v logView) { templ dishPill(d Dish, v logView) {
<a <a
class={ "pill", d.Size() } class={ "pill", d.Size() }
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) } href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
> >
@categoryIcon(d.CategoryKey()) @categoryIcon(d.CategoryKey())
{ d.Name } { d.Name }
@@ -454,7 +548,11 @@ templ sidesStep(v logView) {
@categoryIcon(v.Chosen.CategoryKey()) @categoryIcon(v.Chosen.CategoryKey())
{ v.Chosen.Name } { v.Chosen.Name }
</h3> </h3>
<form method="post" action="/kirjaa"> <form
method="post"
action="/kirjaa"
data-on:submit__prevent="@post('/kirjaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/> <input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
if v.Chosen.HasSides && len(v.Sides) > 0 { if v.Chosen.HasSides && len(v.Sides) > 0 {
@@ -476,7 +574,11 @@ templ sidesStep(v logView) {
} }
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
<a class="ghost" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a> <a
class="ghost"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</section> </section>
} }
@@ -497,21 +599,31 @@ templ loggedCard(v logView) {
if v.Confirming { if v.Confirming {
<p class="q">Poistetaanko merkintä?</p> <p class="q">Poistetaanko merkintä?</p>
<div class="pair"> <div class="pair">
<form method="post" action="/poista"> <form
method="post"
action="/poista"
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/> <input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button> <button class="btn del" type="submit">Kyllä, poista</button>
</form> </form>
<a class="btn" href={ templ.SafeURL(dayURL("/", v.Date, v.Today)) }>Peruuta</a> <a
class="btn"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</div> </div>
} else { } else {
<div class="pair"> <div class="pair">
<a <a
class="btn" class="btn"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "muuta=1") } href={ templ.SafeURL(stepURL(v, "muuta=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
>Muokkaa</a> >Muokkaa</a>
<a <a
class="btn del" class="btn del"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "poista=1") } href={ templ.SafeURL(stepURL(v, "poista=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
>Poista</a> >Poista</a>
</div> </div>
} }
@@ -531,8 +643,6 @@ templ catalogPage(v catalogView) {
if v.Report != nil { if v.Report != nil {
@importReport(v.Report) @importReport(v.Report)
} }
@mainForm_(v.Main)
@sideForm_(v.Side)
<div data-signals:haku={ jsString(v.Search) }> <div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/ruuat" class="searchrow"> <form method="get" action="/ruuat" class="searchrow">
<input <input
@@ -546,6 +656,10 @@ templ catalogPage(v catalogView) {
data-on:input__debounce.250ms="@get('/ruuat/etsi')" data-on:input__debounce.250ms="@get('/ruuat/etsi')"
/> />
</form> </form>
// The add and edit forms stay outside the patched fragment, or
// typing in the search box would collapse a form mid-edit.
@mainForm_(v.Main)
@sideForm_(v.Side)
@catalogList(v) @catalogList(v)
</div> </div>
<details class="card"> <details class="card">
@@ -557,21 +671,21 @@ templ catalogPage(v catalogView) {
} }
// catalogList carries the id Datastar patches, so typing in the search box // catalogList carries the id Datastar patches, so typing in the search box
// swaps the lists without reloading the forms above them. // swaps the lists without touching the forms above them.
//
// Two levels of heading, because there are two: Pääruuat and Lisukkeet are
// the halves of the catalog, and the categories are subdivisions of the
// first. They were previously styled the same, which made a category look
// like a peer of the entire side-dish list.
templ catalogList(v catalogView) { templ catalogList(v catalogView) {
<div id="ruokalista"> <div id="ruokalista">
<section class="section">
@sectionTitle("Pääruuat", v.Mains)
if v.Mains == 0 { if v.Mains == 0 {
<h3 class="sechead">Pääruuat</h3> @emptyNote(v.Search)
<p class="muted small">
if v.Search == "" {
Ei vielä pääruokia.
} else {
Ei osumia.
} }
</p> // Grouped by category, alphabetical inside. The catalog is a list
} // you manage, so a predictable position beats a useful one.
// Grouped by category, alphabetical inside. The catalog is a list you
// manage, so a predictable position beats a useful one.
for _, g := range v.Groups { for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3> <h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes { for _, d := range g.Dishes {
@@ -587,15 +701,11 @@ templ catalogList(v catalogView) {
</div> </div>
} }
} }
<h3 class="sechead">Lisukkeet</h3> </section>
<section class="section">
@sectionTitle("Lisukkeet", len(v.Sides))
if len(v.Sides) == 0 { if len(v.Sides) == 0 {
<p class="muted small"> @emptyNote(v.Search)
if v.Search == "" {
Ei vielä lisukkeita.
} else {
Ei osumia.
}
</p>
} }
for _, s := range v.Sides { for _, s := range v.Sides {
<div class="row"> <div class="row">
@@ -603,31 +713,63 @@ templ catalogList(v catalogView) {
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke") @rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div> </div>
} }
</section>
</div> </div>
} }
templ sectionTitle(label string, n int) {
<h2 class="sectiontitle">
{ label }
<span class="count">{ strconv.Itoa(n) }</span>
</h2>
}
templ emptyNote(search string) {
<p class="muted small">
if search == "" {
Ei vielä mitään.
} else {
Ei osumia haulle { search }.
}
</p>
}
// rowActions is a pencil and a bin, until the bin is tapped: then the row // rowActions is a pencil and a bin, until the bin is tapped: then the row
// asks. An icon is a smaller target to hit by accident than a word, and the // asks. An icon is a smaller target to hit by accident than a word, and the
// dish disappears from every picker the moment it goes. // dish disappears from every picker the moment it goes.
// Every control here is a real link or form, so the page still works without
// JavaScript. Datastar intercepts them and patches the list in place instead,
// which is the whole point: a delete confirmation halfway down a long list
// must not send the browser back to the top.
templ rowActions(v catalogView, editURL string, id int64, kind string) { templ rowActions(v catalogView, editURL string, id int64, kind string) {
if v.DeleteID == id && v.DeleteKind == kind { if v.DeleteID == id && v.DeleteKind == kind {
<div class="rowactions confirming"> <div class="rowactions confirming">
<span>Poista?</span> <span>Poista?</span>
<form method="post" action="/ruuat/poista"> <form
method="post"
action="/ruuat/poista"
data-on:submit__prevent="@post('/ruuat/poista', {contentType: 'form'})"
>
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/> <input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button> <button type="submit" class="del">Kyllä</button>
</form> </form>
<a href="/ruuat">Peruuta</a> <a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
</div> </div>
} else { } else {
<div class="rowactions"> <div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa"> <a
href={ templ.SafeURL(editURL) }
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
aria-label="Muokkaa"
title="Muokkaa"
>
@iconPencil() @iconPencil()
</a> </a>
<a <a
class="del" class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) } href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
data-on:click__prevent={ "@get('/ruuat/nayta?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind + "')" }
aria-label="Poista" aria-label="Poista"
title="Poista" title="Poista"
> >
@@ -651,19 +793,26 @@ templ iconTrash() {
</svg> </svg>
} }
// Collapsed by default so the page opens on the catalog rather than on two
// screens of empty form. Forced open when editing or after a rejected
// submission, since the form is then the thing that needs attention.
templ mainForm_(f mainForm) { templ mainForm_(f mainForm) {
<section class="card" id="paaruoka"> <details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }>
<h3> <summary>
if f.ID == 0 { if f.ID == 0 {
Lisää pääruoka Lisää pääruoka
} else { } else {
Muokkaa pääruokaa Muokkaa pääruokaa
} }
</h3> </summary>
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruuat/paaruoka"> <form
method="post"
action="/ruuat/paaruoka"
data-on:submit__prevent="@post('/ruuat/paaruoka', {contentType: 'form'})"
>
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -691,9 +840,9 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a> <a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
} }
</section> </details>
} }
templ categoryChip(value, label string, f mainForm) { templ categoryChip(value, label string, f mainForm) {
@@ -709,18 +858,22 @@ templ categoryChip(value, label string, f mainForm) {
} }
templ sideForm_(f sideForm) { templ sideForm_(f sideForm) {
<section class="card" id="lisuke"> <details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }>
<h3> <summary>
if f.ID == 0 { if f.ID == 0 {
Lisää lisuke Lisää lisuke
} else { } else {
Muokkaa lisuketta Muokkaa lisuketta
} }
</h3> </summary>
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruuat/lisuke"> <form
method="post"
action="/ruuat/lisuke"
data-on:submit__prevent="@post('/ruuat/lisuke', {contentType: 'form'})"
>
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -731,9 +884,9 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a> <a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
} }
</section> </details>
} }
templ importForm() { templ importForm() {
+21 -11
View File
@@ -1,29 +1,39 @@
services: services:
app: app:
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest} image: ${REPO:?set REPO in .env}:${TAG:-latest}
restart: unless-stopped restart: unless-stopped
# The database is a bind mount, not a named volume: it sits in ./data on # A bind mount rather than a named volume: the database sits in ./data on
# the host where it can be listed, copied and opened with any sqlite # the host, where it can be listed, copied and backed up without going
# client. The image runs as UID 65534, so the container has to be told # through the container engine. The image runs as UID 65534, so the
# which host user owns that directory. # container has to be told which host user owns that directory.
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}" #
# PUID/PGID rather than UID/GID: UID is a read-only variable in bash, so a
# value set here would be silently replaced by the invoking shell's own.
user: "${PUID:-1000}:${PGID:-1000}"
volumes: volumes:
- ./data:/data - ./data:/data
environment: environment:
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env} DB: /data/foodster.db
FOODSTER_DB: /data/foodster.db ENV: ${ENV:-prod}
TZ: ${TZ:-Europe/Helsinki} TZ: ${TZ:-Europe/Helsinki}
# No published ports: Traefik reaches the container over the shared # No published ports: Traefik reaches the container over the shared
# network. Publishing 8080 as well would put an unencrypted copy of the # network. Publishing 8080 as well would put an unencrypted copy of the
# app on the host, bypassing TLS. # app on the host, bypassing TLS — and, now that the app has no login of
# its own, bypassing authentication entirely.
#
# The middleware is the only thing standing in front of the app. If AUTH
# is unset or names a middleware Traefik does not know, Traefik takes the
# router out of service rather than serving it open, so a typo fails shut.
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.foodster.entrypoints=websecure - traefik.http.routers.foodster.entrypoints=websecure
- traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`) - traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`)
- traefik.http.routers.foodster.tls=true # Naming a resolver implies tls=true, so this is one label, not two.
- traefik.http.routers.foodster.tls.certresolver=${CERTRESOLVER:?set CERTRESOLVER in .env}
- traefik.http.routers.foodster.middlewares=${AUTH:?set AUTH in .env, e.g. authelia@docker}
- traefik.http.services.foodster.loadbalancer.server.port=8080 - traefik.http.services.foodster.loadbalancer.server.port=8080
- traefik.docker.network=traefik - traefik.docker.network=traefik
networks: networks:
-1
View File
@@ -6,7 +6,6 @@ tool github.com/a-h/templ/cmd/templ
require ( require (
github.com/a-h/templ v0.3.1020 github.com/a-h/templ v0.3.1020
golang.org/x/time v0.15.0
modernc.org/sqlite v1.58.0 modernc.org/sqlite v1.58.0
) )
-2
View File
@@ -50,8 +50,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+135 -65
View File
@@ -1,7 +1,10 @@
#!/bin/sh #!/bin/sh
# End-to-end check of a running Foodster: auth, static assets and the bundle # End-to-end check of a running Foodster: static assets, the logger and the
# import flow. Builds its own binary, uses a scratch database and a spare # bundle import flow. Builds its own binary, uses a scratch database and a
# port, and cleans up after itself, so it never touches a real instance. # spare port, and cleans up after itself, so it never touches a real instance.
#
# There is nothing to authenticate as: the app is served behind Authelia and
# has no login of its own.
# #
# Run it with `make smoke`. # Run it with `make smoke`.
@@ -10,13 +13,18 @@ set -eu
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
addr=127.0.0.1:8099 addr=127.0.0.1:8099
pass=smoke
# Dates are relative, never literal. A hardcoded one turns into "some day in
# the past" at the next midnight, and the assertions quietly start meaning
# something else.
d0=$(date +%F)
d1=$(date -d yesterday +%F)
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
go build -o "$tmp/foodster" ./cmd/foodster go build -o "$tmp/foodster" ./cmd/foodster
FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \ DB="$tmp/smoke.db" ADDR="$addr" \
"$tmp/foodster" >"$tmp/server.log" 2>&1 & "$tmp/foodster" >"$tmp/server.log" 2>&1 &
srv=$! srv=$!
@@ -55,65 +63,69 @@ refute() {
echo "smoke: http://$addr" echo "smoke: http://$addr"
check "unauthenticated request is refused" \ check "healthz answers" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/")" "401"
check "healthz needs no password" \
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200" "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200"
check "datastar client is served" \ check "datastar client is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/datastar.js")" "200" "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/datastar.js")" "200"
check "favicon is served" \ check "favicon is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/favicon.svg")" "200" "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/favicon.svg")" "200"
check "theme script is served" \ check "theme script is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/theme.js")" "200" "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/theme.js")" "200"
home=$(curl -s -u ":$pass" "http://$addr/") home=$(curl -s "http://$addr/")
check "the header carries the brand" "$home" "Foodster" check "the header carries the brand" "$home" "Foodster"
# ENV is unset here, so this instance is production and unmarked.
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">' check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
check "the theme toggle is present" "$home" "data-theme-toggle" check "the theme toggle is present" "$home" "data-theme-toggle"
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"' check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
check "apple touch icon is served" \ check "apple touch icon is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/apple-touch-icon.png")" "200" "$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/apple-touch-icon.png")" "200"
# A manifest served as octet-stream is silently ignored by the browser. # A manifest served as octet-stream is silently ignored by the browser.
check "manifest has the right content type" \ check "manifest has the right content type" \
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" "http://$addr/static/manifest.webmanifest")" \ "$(curl -s -o /dev/null -w '%{content_type}' "http://$addr/static/manifest.webmanifest")" \
"application/manifest+json" "application/manifest+json"
check "catalog starts empty" \ check "catalog starts empty" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa" "$(curl -s "http://$addr/ruuat")" "0 pääruokaa"
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti") out=$(curl -s -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0" check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta" check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
check "re-import refuses duplicates" \ check "re-import refuses duplicates" \
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \ "$(curl -s -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
"jo listalla" "jo listalla"
check "pasted JSON imports" \ check "pasted JSON imports" \
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \ "$(curl -s -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
"http://$addr/ruuat/tuonti")" "Lisätty 1" "http://$addr/ruuat/tuonti")" "Lisätty 1"
check "unknown category is reported" \ check "unknown category is reported" \
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \ "$(curl -s -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
"http://$addr/ruuat/tuonti")" "tuntematon kategoria" "http://$addr/ruuat/tuonti")" "tuntematon kategoria"
check "empty submit is explained" \ check "empty submit is explained" \
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa" "$(curl -s -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
check "malformed JSON is explained" \ check "malformed JSON is explained" \
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa" "$(curl -s -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
# ---- the log flow, against the dishes imported above -------------------- # ---- the log flow, against the dishes imported above --------------------
board=$(curl -s -u ":$pass" "http://$addr/") board=$(curl -s "http://$addr/")
check "board lists imported dishes" "$board" "Lihapullat" check "board lists imported dishes" "$board" "Lihapullat"
# Tähteet is loggable but is not food: on the board, never in the catalog.
check "leftovers are on the board" "$board" "Tähteet"
refute "leftovers are not in the catalog" \
"$(curl -s "http://$addr/ruuat")" "Tähteet"
# Pull a real dish id out of the board rather than assuming one. # Pull a real dish id out of the board rather than assuming one.
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2) ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
if [ -z "$ruoka" ]; then if [ -z "$ruoka" ]; then
@@ -123,115 +135,147 @@ if [ -z "$ruoka" ]; then
fi fi
check "picking a dish opens the sides step" \ check "picking a dish opens the sides step" \
"$(curl -s -u ":$pass" "http://$addr/?ruoka=$ruoka")" "Tallenna" "$(curl -s "http://$addr/?ruoka=$ruoka")" "Tallenna"
check "saving redirects back to the day" \ check "saving redirects back to the day" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' \
-d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303" -d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
check "the saved day shows what was eaten" \ check "the saved day shows what was eaten" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu" "$(curl -s "http://$addr/?pvm=$d0")" "kirjattu"
check "history is on the same page as the logger" \ # The selected day expands inside the list rather than in a panel above it,
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin" # so the rows below do not shift when one is tapped.
day=$(curl -s "http://$addr/?pvm=$d0")
check "the selected day expands in place" "$day" 'class="open"'
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
# ---- the day list patches in place instead of navigating ----------------
dayp=$(curl -s -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0")
check "opening a day patches the list" "$dayp" 'id="paivat"'
refute "and returns a fragment, not a page" "$dayp" "<html"
check "picking a dish patches to the sides step" \
"$(curl -s -H 'Datastar-Request: true' \
"http://$addr/paiva?pvm=$d0&ruoka=$ruoka")" "Tallenna"
check "saving from Datastar patches back" \
"$(curl -s -H 'Datastar-Request: true' \
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
check "deleting from Datastar patches back" \
"$(curl -s -H 'Datastar-Request: true' \
-d "pvm=$d1" "http://$addr/poista")" 'id="paivat"'
# Without the header it must still redirect, for no JavaScript.
check "a plain save still redirects to the day" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=$d1"
# Deleting a logged meal drops the row outright, so it asks first. # Deleting a logged meal drops the row outright, so it asks first.
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1") saved=$(curl -s "http://$addr/?pvm=$d0&poista=1")
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?" check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
refute "and does not delete while asking" "$saved" "Ei merkintää" # Assert the entry is still shown, rather than that no gap row exists anywhere
# on the page: other days are legitimately unlogged and render their own.
check "and the entry is still there while asking" "$saved" "kirjattu"
check "deleting redirects back" \ check "deleting redirects back" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{http_code}' \
-d "pvm=2026-09-05" "http://$addr/poista")" "303" -d "pvm=$d0" "http://$addr/poista")" "303"
check "the day is empty again" \ check "the day is empty again" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "Etsi" "$(curl -s "http://$addr/?pvm=$d0")" "Etsi"
check "search filters the board" \ check "search filters the board" \
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto" "$(curl -s "http://$addr/?haku=keitto")" "keitto"
# ---- live search: Datastar sends signals as JSON in ?datastar= ----------- # ---- live search: Datastar sends signals as JSON in ?datastar= -----------
live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi") live=$(curl -s --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
check "live search returns the board fragment" "$live" 'id="lauta"' check "live search returns the board fragment" "$live" 'id="lauta"'
check "live search applies the term" "$live" "keitto" check "live search applies the term" "$live" "keitto"
refute "live search excludes non-matches" "$live" "Lihapullat" refute "live search excludes non-matches" "$live" "Lihapullat"
refute "the fragment is not a whole page" "$live" "<html" refute "the fragment is not a whole page" "$live" "<html"
check "live search is served as html for Datastar to patch" \ check "live search is served as html for Datastar to patch" \
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{content_type}' \
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \ --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
"text/html" "text/html"
cat_live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi") cat_live=$(curl -s --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"' check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
check "catalog live search matches sides too" "$cat_live" "Riisi" check "catalog live search matches sides too" "$cat_live" "Riisi"
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat" refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
# The plain form still works without JavaScript. # The plain form still works without JavaScript.
check "catalog search works as a plain form too" \ check "catalog search works as a plain form too" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?haku=riisi")" "Riisi" "$(curl -s "http://$addr/ruuat?haku=riisi")" "Riisi"
# Nothing was eaten tomorrow. A future date is clamped rather than logged. # Nothing was eaten tomorrow. A future date is clamped rather than logged.
future=$(date -d '+30 days' +%Y-%m-%d) future=$(date -d '+30 days' +%Y-%m-%d)
check "a future date falls back to today" \ check "a future date falls back to today" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)" "$(curl -s "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
check "saving a future date is clamped too" \ check "saving a future date is clamped too" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{redirect_url}' \
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/" -d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
check "tomorrow was not written to the log" \ check "tomorrow was not written to the log" \
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)" "$(curl -s "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
# Clean up the entry that clamped onto today. # Clean up the entry that clamped onto today.
curl -s -o /dev/null -u ":$pass" -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista" curl -s -o /dev/null -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
# ---- adding a dish without leaving Kirjaa -------------------------------- # ---- adding a dish without leaving Kirjaa --------------------------------
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste") miss=$(curl -s "http://$addr/?haku=Poronkariste")
check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?" check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?"
check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"' check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"'
check "quick add goes straight to the sides step" \ check "quick add goes straight to the sides step" \
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \ -d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \
"ruoka=" "ruoka="
check "quick add rejects a dish with no category" \ check "quick add rejects a dish with no category" \
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \ "$(curl -s -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \
"Valitse vähintään yksi kategoria." "Valitse vähintään yksi kategoria."
check "the quick-added dish is on the board" \ check "the quick-added dish is on the board" \
"$(curl -s -u ":$pass" "http://$addr/")" "Poronkariste" "$(curl -s "http://$addr/")" "Poronkariste"
# ---- catalog CRUD from the UI ------------------------------------------- # ---- catalog CRUD from the UI -------------------------------------------
check "adding a main redirects" \ # Assert where it redirects, not just that it does: these pointed at the old
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ # /ruoat spelling for a while and every 303-only check was happy.
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303" check "adding a main redirects back to the catalog" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat") catalog=$(curl -s "http://$addr/ruuat")
check "the new main is listed, sentence-cased" "$catalog" "Uunikala" check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
check "a duplicate name is refused" \ check "a duplicate name is refused" \
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Nimi on jo listalla." "Nimi on jo listalla."
check "a main with no category is refused" \ check "a main with no category is refused" \
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
"Valitse vähintään yksi kategoria." "Valitse vähintään yksi kategoria."
check "a nameless dish is refused" \ check "a nameless dish is refused" \
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "$(curl -s -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"Anna nimi." "Anna nimi."
check "adding a side redirects" \ check "adding a side redirects back to the catalog" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \ "$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303" -d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
"/ruuat"
check "the new side is listed" \ check "the new side is listed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat" "$(curl -s "http://$addr/ruuat")" "Lohkoperunat"
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so # The id of Uunikala specifically: the catalog is grouped and alphabetical, so
# the first id on the page belongs to some other dish entirely. # the first id on the page belongs to some other dish entirely.
@@ -242,21 +286,47 @@ if [ -z "$uusi" ]; then
uusi=0 uusi=0
fi fi
check "the edit form is prefilled" \ check "the edit form is prefilled" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa" "$(curl -s "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
# A bin icon is easy to hit by accident, so the row asks before anything goes. # A bin icon is easy to hit by accident, so the row asks before anything goes.
check "the bin asks before deleting" \ check "the bin asks before deleting" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?" "$(curl -s "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
check "the dish is still there while it asks" \ check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala" "$(curl -s "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
check "confirming the delete redirects" \ # ---- the catalog patches in place instead of navigating -----------------
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303" # A delete confirmation halfway down a long list must not send the browser
# back to the top, so these answer with a Datastar patch rather than a page.
patch=$(curl -s -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
check "the patch carries the list" "$patch" 'id="ruokalista"'
check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
check "the row it patches in is asking" "$patch" "Poista?"
check "patches are served as an event stream" \
"$(curl -s -o /dev/null -w '%{content_type}' -H 'Datastar-Request: true' \
"http://$addr/ruuat/nayta")" "text/event-stream"
check "deleting from Datastar patches too" \
"$(curl -s -H 'Datastar-Request: true' \
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
"event: datastar-patch-elements"
refute "and the dish is gone from the patched list" \
"$(curl -s -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
"Uunikala"
# Without the header it must still be an ordinary redirect, for no JavaScript.
check "a plain form post still redirects" \
"$(curl -s -o /dev/null -w '%{redirect_url}' \
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
"/ruuat"
refute "the dish is gone once confirmed" \ refute "the dish is gone once confirmed" \
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala" "$(curl -s "http://$addr/ruuat")" "Uunikala"
if [ "$fail" -ne 0 ]; then if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED" echo "smoke: FAILED"
+44 -7
View File
@@ -1,19 +1,56 @@
{ {
"mains": [ "mains": [
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true}, {"name": "Jauheliha-perunasiivu pelti", "categories": ["meat"], "has_sides": false},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true}, {"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false}, {"name": "Jauhelihakeitto", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihapihvit", "categories": ["meat"], "has_sides": true},
{"name": "Kebab", "categories": ["meat"], "has_sides": true},
{"name": "Kinkkukiusaus", "categories": ["meat"], "has_sides": false},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Lihapullat/pihvit", "categories": ["meat"], "has_sides": true},
{"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Makaronimössö", "categories": ["meat"], "has_sides": false},
{"name": "Maksalaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Nachopelti", "categories": ["meat"], "has_sides": false},
{"name": "Nakkikeitto", "categories": ["meat"], "has_sides": false},
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false}, {"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
{"name": "Possunsuikalekastike", "categories": ["meat"], "has_sides": true},
{"name": "Possurisotto", "categories": ["meat"], "has_sides": false},
{"name": "Pyttipannu", "categories": ["meat"], "has_sides": false},
{"name": "Uuniliha", "categories": ["meat"], "has_sides": true},
{"name": "Uunimakkara", "categories": ["meat"], "has_sides": true},
{"name": "Broilerin koipireidet", "categories": ["chicken"], "has_sides": true},
{"name": "Kanakastike", "categories": ["chicken"], "has_sides": true},
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false}, {"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false} {"name": "Kanamakaronilaatikko", "categories": ["chicken"], "has_sides": false},
{"name": "Kanapasta", "categories": ["chicken"], "has_sides": false},
{"name": "Kanarisotto", "categories": ["chicken"], "has_sides": false},
{"name": "Kalakeitto", "categories": ["fish"], "has_sides": false},
{"name": "Lohicuscus-salaatti", "categories": ["fish"], "has_sides": false},
{"name": "Lohipyörykät", "categories": ["fish"], "has_sides": true},
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
{"name": "Uuniperunat (lohitäytteellä)", "categories": ["fish"], "has_sides": false},
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Italianpata (lihaton)", "categories": ["vegetarian"], "has_sides": true},
{"name": "Kasvispihvit", "categories": ["vegetarian"], "has_sides": true},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Pinaattiletut", "categories": ["vegetarian"], "has_sides": false},
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
], ],
"sides": [ "sides": [
{"name": "Keitetyt perunat"}, {"name": "Keitetyt perunat"},
{"name": "Ranskalaiset"},
{"name": "Lohkoperunat"}, {"name": "Lohkoperunat"},
{"name": "Muussi"}, {"name": "Muusi"},
{"name": "Pasta"},
{"name": "Ranskalaiset"},
{"name": "Riisi"}, {"name": "Riisi"},
{"name": "Pasta"} {"name": "Spagetti"},
{"name": "Tillikastike"},
{"name": "Wokkivihannekset"}
] ]
} }