Compare commits
4
Commits
768ab50140
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e413fbbb | ||
|
|
57d5faf65f | ||
|
|
d8810d288e | ||
|
|
174652778b |
+21
-9
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
# Build output
|
# Build output
|
||||||
/foodster
|
/foodster
|
||||||
|
|
||||||
# The tag `make image` last built, handed to `make push`.
|
|
||||||
/.release-tag
|
|
||||||
|
|
||||||
# Generated by `templ generate` during the container build.
|
# Generated by `templ generate` during the container build.
|
||||||
*_templ.go
|
*_templ.go
|
||||||
|
|
||||||
|
|||||||
+104
@@ -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,18 +3,8 @@
|
|||||||
COMPOSE ?= podman compose
|
COMPOSE ?= podman compose
|
||||||
BIN := foodster
|
BIN := foodster
|
||||||
PKG := ./cmd/foodster
|
PKG := ./cmd/foodster
|
||||||
STATIC := cmd/foodster/static
|
|
||||||
|
|
||||||
# What `make image` last built. push reads it rather than re-deriving the tag:
|
# Registry coordinates, hostname, TZ. Gitignored.
|
||||||
# sorting tags by date is ambiguous when two point at the same commit, and
|
|
||||||
# re-deriving is what let a parallel make push the wrong one.
|
|
||||||
TAGFILE := .release-tag
|
|
||||||
|
|
||||||
# Vendored Datastar client. Bump, run `make vendor`, commit the result.
|
|
||||||
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
|
||||||
@@ -24,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) \
|
||||||
@@ -38,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 ./...
|
||||||
@@ -57,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); \
|
||||||
@@ -84,57 +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" . ; \
|
|
||||||
echo "$$tag" > $(TAGFILE)
|
|
||||||
|
|
||||||
# Pushing reported success while uploading the previous release once, because
|
|
||||||
# nothing compared what was built against what arrived. So afterwards, ask the
|
|
||||||
# registry what it actually serves for each tag and fail if it is not the
|
|
||||||
# image we just built.
|
|
||||||
push: ## Push the newest tag and :latest, then verify the registry
|
|
||||||
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
|
||||||
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
|
|
||||||
tag=$$(cat $(TAGFILE)); \
|
|
||||||
built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
|
|
||||||
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
|
|
||||||
podman push "$(FOODSTER_REPO):$$tag"; \
|
|
||||||
podman push "$(FOODSTER_REPO):latest"; \
|
|
||||||
echo "==> verifying $$tag"; \
|
|
||||||
for ref in "$$tag" latest; do \
|
|
||||||
podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \
|
|
||||||
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
|
|
||||||
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \
|
|
||||||
if [ "$$served" != "$$built" ]; then \
|
|
||||||
echo " FAIL $$ref serves $$served"; \
|
|
||||||
echo " expected $$built"; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
echo " ok $$ref"; \
|
|
||||||
done
|
|
||||||
|
|
||||||
# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on
|
|
||||||
# at least one machine here — these run concurrently, so push resolves the
|
|
||||||
# newest tag and uploads :latest before image has finished building and
|
|
||||||
# tagging. That silently ships the previous release a second time.
|
|
||||||
release: ## Build, tag and push in one go
|
|
||||||
@$(MAKE) --no-print-directory image
|
|
||||||
@$(MAKE) --no-print-directory push
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -350,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.
|
||||||
|
|
||||||
@@ -372,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.
|
||||||
@@ -395,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
+170
-47
@@ -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
|
||||||
}
|
}
|
||||||
@@ -98,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)
|
||||||
@@ -118,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 {
|
||||||
@@ -134,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 {
|
||||||
@@ -157,7 +204,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.loadDays(r, &v)
|
a.loadDays(r, &v)
|
||||||
render(w, r, logPage(v))
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDays fills the day list. The selected day expands inside it rather than
|
// loadDays fills the day list. The selected day expands inside it rather than
|
||||||
@@ -165,15 +212,13 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
// list underneath the tap.
|
// list underneath the tap.
|
||||||
func (a *app) loadDays(r *http.Request, v *logView) {
|
func (a *app) loadDays(r *http.Request, v *logView) {
|
||||||
v.HistoryDays = historyWindow(r)
|
v.HistoryDays = historyWindow(r)
|
||||||
|
|
||||||
// The window has to reach the selected day, or it would have nowhere to
|
|
||||||
// expand.
|
|
||||||
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
|
|
||||||
v.HistoryDays = min(reach, maxHistoryDays)
|
|
||||||
}
|
|
||||||
v.HistoryMore = v.HistoryDays + historyDays
|
v.HistoryMore = v.HistoryDays + historyDays
|
||||||
|
|
||||||
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
|
// 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 {
|
if err != nil {
|
||||||
log.Printf("history: %v", err)
|
log.Printf("history: %v", err)
|
||||||
}
|
}
|
||||||
@@ -215,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
|
||||||
@@ -300,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
|
||||||
}
|
}
|
||||||
@@ -307,23 +402,12 @@ 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)
|
|
||||||
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
|
||||||
log.Printf("list special: %v", err)
|
|
||||||
}
|
|
||||||
a.loadDays(r, &v)
|
|
||||||
render(w, r, logPage(v))
|
render(w, r, logPage(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,9 +417,7 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T
|
|||||||
if strings.Contains(target, "?") {
|
if strings.Contains(target, "?") {
|
||||||
sep = "&"
|
sep = "&"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r,
|
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date),
|
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// save records the meal and redirects, so a refresh cannot double-post.
|
// save records the meal and redirects, so a refresh cannot double-post.
|
||||||
@@ -360,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) {
|
||||||
@@ -370,13 +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 returns to the day in the list, anchor included, so saving or
|
// redirectToDay is the no-JavaScript path back after a write.
|
||||||
// deleting leaves the viewport where the work was happening.
|
|
||||||
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) {
|
||||||
http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther)
|
http.Redirect(w, r, dayURL("/", date, today(a.loc)), 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
|
||||||
@@ -410,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")),
|
||||||
@@ -446,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{}
|
||||||
}
|
}
|
||||||
@@ -465,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) {
|
||||||
@@ -508,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) {
|
||||||
@@ -537,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).
|
||||||
@@ -565,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
@@ -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
|
||||||
|
|||||||
+31
-48
@@ -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 {
|
||||||
@@ -283,63 +306,23 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-6
@@ -305,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)
|
||||||
@@ -345,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,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 {
|
||||||
@@ -457,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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+101
-31
@@ -43,16 +43,21 @@ func dayURL(base string, d, now time.Time) string {
|
|||||||
return base + "?pvm=" + isoDate(d)
|
return base + "?pvm=" + isoDate(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayAnchor names the element a day expands into.
|
// showURL turns a catalog page link into the patch endpoint behind it, so the
|
||||||
func dayAnchor(d time.Time) string {
|
// href and the Datastar call never drift apart.
|
||||||
return "paiva-" + isoDate(d)
|
func showURL(pageURL string) string {
|
||||||
|
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayLink opens a day and lands the viewport on it. Without the fragment the
|
// dayPatch is the endpoint behind every link in the day list. The href beside
|
||||||
// browser would jump to the top of a page whose selected day might be far
|
// it stays a real page URL for anyone without JavaScript; Datastar calls this
|
||||||
// down the list.
|
// instead and swaps the list where it stands.
|
||||||
func dayLink(d, now time.Time) string {
|
func dayPatch(d time.Time, param string) string {
|
||||||
return dayURL("/", d, now) + "#" + dayAnchor(d)
|
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
|
||||||
@@ -64,15 +69,14 @@ func pickSeparator(v logView) string {
|
|||||||
return "&"
|
return "&"
|
||||||
}
|
}
|
||||||
|
|
||||||
// stepURL is any link inside the open day. It keeps the anchor, so picking a
|
// stepURL is the page URL for a link inside the open day: the fallback when
|
||||||
// dish or cancelling stays where the day is instead of throwing the viewport
|
// there is no JavaScript to intercept it.
|
||||||
// back to the top of the list.
|
|
||||||
func stepURL(v logView, param string) string {
|
func stepURL(v logView, param string) string {
|
||||||
url := dayURL("/", v.Date, v.Today)
|
url := dayURL("/", v.Date, v.Today)
|
||||||
if param != "" {
|
if param != "" {
|
||||||
url += pickSeparator(v) + param
|
url += pickSeparator(v) + param
|
||||||
}
|
}
|
||||||
return url + "#" + dayAnchor(v.Date)
|
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
|
||||||
@@ -104,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 {
|
||||||
@@ -126,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. -->
|
||||||
@@ -314,13 +329,13 @@ templ logPage(v logView) {
|
|||||||
// above the list and drop that day out of it, so the rows below jumped up
|
// 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.
|
// under the tap. Now nothing moves — the row grows.
|
||||||
templ dayList(v logView) {
|
templ dayList(v logView) {
|
||||||
<section class="history">
|
<section class="history" id="paivat">
|
||||||
for i, row := range v.History.Rows {
|
for i, row := range v.History.Rows {
|
||||||
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
||||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||||
}
|
}
|
||||||
if row.Date.Equal(v.Date) {
|
if row.Date.Equal(v.Date) {
|
||||||
<div class="open" id={ dayAnchor(row.Date) }>
|
<div class="open">
|
||||||
<p class="openday">
|
<p class="openday">
|
||||||
if row.Date.Equal(v.Today) {
|
if row.Date.Equal(v.Today) {
|
||||||
Tänään
|
Tänään
|
||||||
@@ -338,7 +353,11 @@ templ dayList(v logView) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} else if row.Entry != nil {
|
} else if row.Entry != nil {
|
||||||
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
<a
|
||||||
|
class="entry"
|
||||||
|
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>
|
||||||
<div>
|
<div>
|
||||||
<div class="nm">
|
<div class="nm">
|
||||||
@@ -350,7 +369,11 @@ templ dayList(v logView) {
|
|||||||
<span class="chev">›</span>
|
<span class="chev">›</span>
|
||||||
</a>
|
</a>
|
||||||
} else {
|
} else {
|
||||||
<a class="gapline" href={ templ.SafeURL(dayLink(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>
|
||||||
@@ -361,6 +384,7 @@ templ dayList(v logView) {
|
|||||||
<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>
|
||||||
@@ -436,6 +460,7 @@ templ boardList(v logView) {
|
|||||||
<a
|
<a
|
||||||
class="pill plain"
|
class="pill plain"
|
||||||
href={ templ.SafeURL(stepURL(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 }
|
||||||
@@ -471,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>
|
||||||
@@ -503,6 +532,7 @@ templ dishPill(d Dish, v logView) {
|
|||||||
<a
|
<a
|
||||||
class={ "pill", d.Size() }
|
class={ "pill", d.Size() }
|
||||||
href={ templ.SafeURL(stepURL(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 }
|
||||||
@@ -518,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 {
|
||||||
@@ -540,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(stepURL(v, "")) }>Peruuta</a>
|
<a
|
||||||
|
class="ghost"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
|
||||||
|
>Peruuta</a>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,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(stepURL(v, "")) }>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(stepURL(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(stepURL(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>
|
||||||
}
|
}
|
||||||
@@ -689,25 +737,39 @@ templ emptyNote(search string) {
|
|||||||
// 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"
|
||||||
>
|
>
|
||||||
@@ -746,7 +808,11 @@ templ mainForm_(f mainForm) {
|
|||||||
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) }/>
|
||||||
}
|
}
|
||||||
@@ -774,7 +840,7 @@ 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>
|
||||||
}
|
}
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
@@ -803,7 +869,11 @@ templ sideForm_(f sideForm) {
|
|||||||
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) }/>
|
||||||
}
|
}
|
||||||
@@ -814,7 +884,7 @@ 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>
|
||||||
}
|
}
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-11
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
+127
-70
@@ -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,69 +63,68 @@ 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.
|
# Tähteet is loggable but is not food: on the board, never in the catalog.
|
||||||
check "leftovers are on the board" "$board" "Tähteet"
|
check "leftovers are on the board" "$board" "Tähteet"
|
||||||
refute "leftovers are not in the catalog" \
|
refute "leftovers are not in the catalog" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
|
"$(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)
|
||||||
@@ -128,123 +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"
|
||||||
|
|
||||||
# The selected day expands inside the list rather than in a panel above it,
|
# The selected day expands inside the list rather than in a panel above it,
|
||||||
# so the rows below do not shift when one is tapped.
|
# so the rows below do not shift when one is tapped.
|
||||||
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
day=$(curl -s "http://$addr/?pvm=$d0")
|
||||||
check "the selected day expands in place" "$day" 'id="paiva-2026-09-05"'
|
check "the selected day expands in place" "$day" 'class="open"'
|
||||||
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
||||||
check "links inside it keep the anchor" "$day" "#paiva-2026-09-05"
|
|
||||||
|
|
||||||
check "saving returns to the day, not the top" \
|
# ---- the day list patches in place instead of navigating ----------------
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
||||||
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04"
|
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.
|
||||||
@@ -255,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user