From 16649e0f2f1a76d2a14921637593ff70418b10f1 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sat, 26 Sep 2026 22:01:51 +0000 Subject: [PATCH] A SimpleX Chat bot that answers arithmetic (closes #1) Remove the template's HTTP service, database and fx wiring. Add exact arithmetic on go/parser and go/constant, a client that runs simplex-chat as a child process and drives its WebSocket API, and the bot, which keeps an auto-accepting address and replies to each message. The image adds the checksum-pinned simplex-chat v7.0.2 on Ubuntu 22.04. Model: opus-5-5 --- .prettierignore | 5 - AGENTS.md | 108 ++--- Dockerfile | 44 +- Makefile | 6 +- README.md | 195 ++++++++ cmd/simplexcalc/main.go | 15 +- cmd/simplexcalc/root.go | 15 +- cmd/simplexcalc/run.go | 58 +++ cmd/simplexcalc/serve.go | 80 ---- docs/TODO.md | 49 +- go.mod | 32 +- go.sum | 101 +--- internal/app/app.go | 72 --- internal/bot/bot.go | 230 ++++++++++ internal/bot/bot_test.go | 35 ++ internal/calc/calc.go | 181 ++++++++ internal/calc/calc_test.go | 150 ++++++ internal/config/config.go | 305 ++---------- internal/config/config_test.go | 235 ++-------- internal/config/export_test.go | 4 - internal/config/hex.go | 29 -- internal/database/database.go | 211 --------- internal/database/database_test.go | 219 --------- internal/database/migrate.go | 199 -------- internal/database/model_widget.go | 106 ----- internal/database/schema/000.sql | 15 - internal/database/schema/001_widgets.sql | 15 - internal/globals/globals.go | 43 -- internal/handlers/errors.go | 13 - internal/handlers/handlers.go | 123 ----- internal/handlers/healthcheck.go | 77 ---- internal/handlers/index.go | 114 ----- internal/logger/logger.go | 70 +-- internal/middleware/bodylimit.go | 46 -- internal/middleware/csrf.go | 116 ----- internal/middleware/middleware.go | 163 ------- internal/middleware/middleware_test.go | 314 ------------- internal/middleware/recover.go | 59 --- internal/middleware/responsewriter.go | 63 --- internal/middleware/security.go | 67 --- internal/render/errors.go | 13 - internal/render/render.go | 240 ---------- internal/render/render_test.go | 197 -------- internal/server/http.go | 55 --- internal/server/routes.go | 87 ---- internal/server/server.go | 180 -------- internal/server/server_test.go | 560 ----------------------- internal/server/staticfs.go | 48 -- internal/simplex/cli.go | 149 ++++++ internal/simplex/client.go | 359 +++++++++++++++ internal/simplex/client_test.go | 320 +++++++++++++ internal/simplex/protocol.go | 204 +++++++++ internal/simplex/protocol_test.go | 57 +++ internal/telemetry/metrics.go | 162 ------- internal/telemetry/metrics_test.go | 152 ------ internal/telemetry/sentry.go | 112 ----- internal/telemetry/testing_test.go | 43 -- static/css/style.css | 135 ------ static/js/app.js | 9 - static/static.go | 12 - templates/base.html | 15 - templates/error.html | 7 - templates/index.html | 49 -- templates/partials/footer.html | 6 - templates/partials/navbar.html | 7 - templates/templates.go | 18 - 66 files changed, 2119 insertions(+), 5059 deletions(-) create mode 100644 cmd/simplexcalc/run.go delete mode 100644 cmd/simplexcalc/serve.go delete mode 100644 internal/app/app.go create mode 100644 internal/bot/bot.go create mode 100644 internal/bot/bot_test.go create mode 100644 internal/calc/calc.go create mode 100644 internal/calc/calc_test.go delete mode 100644 internal/config/hex.go delete mode 100644 internal/database/database.go delete mode 100644 internal/database/database_test.go delete mode 100644 internal/database/migrate.go delete mode 100644 internal/database/model_widget.go delete mode 100644 internal/database/schema/000.sql delete mode 100644 internal/database/schema/001_widgets.sql delete mode 100644 internal/globals/globals.go delete mode 100644 internal/handlers/errors.go delete mode 100644 internal/handlers/handlers.go delete mode 100644 internal/handlers/healthcheck.go delete mode 100644 internal/handlers/index.go delete mode 100644 internal/middleware/bodylimit.go delete mode 100644 internal/middleware/csrf.go delete mode 100644 internal/middleware/middleware.go delete mode 100644 internal/middleware/middleware_test.go delete mode 100644 internal/middleware/recover.go delete mode 100644 internal/middleware/responsewriter.go delete mode 100644 internal/middleware/security.go delete mode 100644 internal/render/errors.go delete mode 100644 internal/render/render.go delete mode 100644 internal/render/render_test.go delete mode 100644 internal/server/http.go delete mode 100644 internal/server/routes.go delete mode 100644 internal/server/server.go delete mode 100644 internal/server/server_test.go delete mode 100644 internal/server/staticfs.go create mode 100644 internal/simplex/cli.go create mode 100644 internal/simplex/client.go create mode 100644 internal/simplex/client_test.go create mode 100644 internal/simplex/protocol.go create mode 100644 internal/simplex/protocol_test.go delete mode 100644 internal/telemetry/metrics.go delete mode 100644 internal/telemetry/metrics_test.go delete mode 100644 internal/telemetry/sentry.go delete mode 100644 internal/telemetry/testing_test.go delete mode 100644 static/css/style.css delete mode 100644 static/js/app.js delete mode 100644 static/static.go delete mode 100644 templates/base.html delete mode 100644 templates/error.html delete mode 100644 templates/index.html delete mode 100644 templates/partials/footer.html delete mode 100644 templates/partials/navbar.html delete mode 100644 templates/templates.go diff --git a/.prettierignore b/.prettierignore index 686797b..5d9f8fd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,8 +2,3 @@ bin/ data/ go.sum LICENSE - -# Go templates are not HTML as far as prettier is concerned: the -# {{ ... }} actions sit in attribute and element position and prettier -# reformats them into invalid template source. -templates/ diff --git a/AGENTS.md b/AGENTS.md index 1216381..59ec12d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ --- title: Agent Guidance -last_modified: 2026-08-22 +last_modified: 2026-09-26 --- This file is the single source of guidance for any automated agent @@ -15,18 +15,20 @@ configuration, instructions or notes addressed to one particular tool. Guidance goes here instead, written so that any agent can follow it. Nothing in this repository names an assistant, model or vendor in its -prose. That rule propagates: every project seeded from this template -inherits it. +prose. Read `docs/REPO_POLICIES.md` as well. Where this file and that one overlap, they agree; where this file is silent, that one governs. ## What this repository is -A template. It is a working HTTP service — it builds, tests, lints and -runs — whose purpose is to be copied. Nothing in it is precious. The -`widgets` table, the index page and the debug panic route are there to -prove the machinery works end to end, and are meant to be deleted. +A SimpleX Chat bot that answers arithmetic. The Go program starts the +SimpleX Chat command-line client (`simplex-chat`) as a child process, +drives it over its local WebSocket API, gives the bot a long-term +contact address that accepts every contact request, and replies to each +text message with the value of the arithmetic in it. Both ship in one +container image; the SimpleX database lives on a volume. It was seeded +from `go-template-repo` and keeps that template's gates and conventions. ## Iron rules @@ -56,17 +58,15 @@ regardless of what else it achieves. 5. **Never use scripted search-and-replace to edit files** — no `sed -i`, `perl -pi`, `awk` rewrites, or scripted heredocs. Read the - file and edit it, even when there are many similar edits. The single - exception is `script/rename`, which is a tool an operator runs once, - against a known-shaped tree, at seed time — and which deletes itself - afterwards. + file and edit it, even when there are many similar edits. 6. **Formatting only via `make fmt`.** Never hand-roll a reformat. `make fmt-check` is the read-only form and must stay non-mutating. 7. **Pin every external reference by hash.** Docker base images by - `@sha256:`, Actions by commit SHA, Go modules by `go.sum`. A version - tag is server-mutable and therefore remote code execution. + `@sha256:`, Actions by commit SHA, Go modules by `go.sum`, the + `simplex-chat` download by the checksum on its `ADD`. A version tag + is server-mutable and therefore remote code execution. 8. **Clean up every container and image you create. Never run `docker builder prune`, `docker image prune`, or any other prune.** @@ -104,9 +104,11 @@ Do not weaken them. ## Branching model - `main` is the default branch and is kept green. -- Work happens on a branch off `main`. -- Merge to `main` directly when the branch is not protected; otherwise - open a PR. +- `next` integrates work toward the next release: branch from `next`, + merge back to `next`. Before 1.0.0, green work may also land directly + on `main`. +- Merge directly when the target branch is not protected; otherwise open + a PR. - `docs/TODO.md` changes go in the same commit as the work they describe. - Push finished work to the remote. Work that exists only locally is @@ -116,63 +118,35 @@ Do not weaken them. ## Layout ``` -cmd/simplexcalc/ cobra command tree; main() and serve -internal/app/ the fx object graph — the only place that knows - which concrete type satisfies what -internal/config/ viper-backed configuration; the abort-on-garbage rule -internal/database/ sqlite handle, embedded schema, migration runner -internal/globals/ build-time metadata (ldflags) -internal/handlers/ HTTP handlers, one struct, fx-injected dependencies -internal/logger/ log/slog, JSON always -internal/middleware/ request id, logging, metrics, recovery, timeout, - body cap, security headers, CSRF -internal/render/ template compilation and execution -internal/server/ lifecycle, route table, static file serving -internal/telemetry/ Sentry and Prometheus -templates/ go:embed HTML: base document, pages, partials -static/ go:embed CSS and JS -script/ the entrypoints +cmd/simplexcalc/ cobra command tree; main(), run and version +internal/bot/ startup, address setup, and the reply to a message +internal/calc/ the arithmetic: go/parser and go/constant +internal/config/ viper-backed configuration; the abort-on-garbage rule +internal/logger/ log/slog, JSON always +internal/simplex/ the simplex-chat child process and its WebSocket API +script/ the entrypoints ``` ## Conventions -- **Dependency injection through `fx`.** A constructor takes an - `fx.Lifecycle` and a `Params` struct; it never reaches for a - package-level singleton. Returning an error from a constructor aborts - startup, which is how configuration failures become refusals to start. - **Logging is `log/slog` only.** Never `zerolog`, never `logrus`. JSON - output in every environment. `net/http`'s own error output is routed - through the same handler so that one process emits one format. + output in every environment. The chat client's own output is logged + line by line through the same handler, so that one process emits one + format. - **Errors wrap with `%w`** and are compared with `errors.Is`. Sentinel - errors are package-level `var`s named `errThing`. -- **A handler never shows an error's text to a client.** The client gets - a chosen message; the error goes to the log and to Sentry, joined by - the request id that is also in the response header. -- **Route labels in metrics are chi route PATTERNS, never paths.** - Labelling by path makes every distinct URL a new time series, and a - crawler then owns the process's memory. -- **Tests exercise behaviour, not implementation.** The server tests - start a real listener against a real database and speak HTTP to it. - Prefer that over asserting on internals. + errors are package-level `var`s. +- **A reply never shows an error's text to a contact.** The contact gets + a chosen sentence; anything unexpected goes to the log. +- **Unknown chat client records are ignored, never fatal.** The SimpleX + API adds fields and record types between releases, and its + documentation requires clients to skip what they do not know. Decode + only the fields that are read. +- **A failure of the chat client or the connection to it ends the + process with an error**, so the container's restart policy restarts + both together. There is no reconnect loop. +- **Tests exercise behaviour, not implementation.** The client tests + speak the real WebSocket protocol to a stand-in chat client. Prefer + that over asserting on internals. - **Comments explain why, and traps.** Delete the history, the reasoning-out-loud and the self-justification; keep what a reader needs in order not to fall in. - -## What to change when seeding a new project - -1. Clone, then run `script/rename [module-path]` on a clean - tree. It rewrites the placeholder name and module path everywhere, - renames `cmd/simplexcalc`, and deletes itself. Review the diff. -2. Delete the example domain: - `internal/database/schema/001_widgets.sql`, - `internal/database/model_widget.go`, the widget parts of - `internal/handlers/index.go` and `templates/index.html`, and their - tests. Start your own schema at `001`. -3. Rewrite `README.md` for the new project. It currently describes the - template, which the new project is not. -4. Rewrite this file's "What this repository is" section, and delete - this list. Keep the iron rules, the entrypoints, the branching model - and the conventions — they are why the template exists. -5. Reset `docs/TODO.md`: keep the Workflow section, replace the rest. -6. Decide on `CSRF_KEY` and the metrics credentials before the first - deployment. See `README.md` for what each one does when unset. diff --git a/Dockerfile b/Dockerfile index 82376a9..3238e1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,8 +71,7 @@ RUN go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -count=1 -timeout 90s -race -v ./...; exit 1; } -# Static build; modernc.org/sqlite is pure Go, so CGO_ENABLED=0 yields -# a fully static binary that runs on the Alpine runtime. +# Static build, so the binary runs on any runtime base. ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath \ -ldflags "-s -w -X main.version=${VERSION}" \ @@ -82,33 +81,42 @@ RUN CGO_ENABLED=0 go build -trimpath \ # BuildKit builds whichever stage is last and appending one drops lint, # builder and their checks out of the run. Nothing asserts the name; it # is here so that failure reads as `[runtime n/m]` in the build log. -# alpine:3.22, 2026-08-07 -FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce AS runtime +# Ubuntu 22.04 because it is the release the SimpleX Chat client below +# is built for, and it already has every library that client links +# (glibc, OpenSSL 3, GMP, zlib), so nothing is installed on top. +# ubuntu:22.04, 2026-09-26 +FROM ubuntu:22.04@sha256:b8b6ee6aa931ecd9d0d952abc34dc0e5f7c6a30c6bb71b079fe399fde0329c02 AS runtime -RUN apk --no-cache add ca-certificates +# The SimpleX Chat command-line client, which the bot starts and talks +# to. BuildKit checks the download against the checksum and fails the +# build on a mismatch; a new release means changing both the URL and +# the checksum. This is the x86_64 build, so the image is x86_64 only; +# an arm64 image would need the aarch64 build and its own checksum. +# simplex-chat v7.0.2 (simplex-chat-ubuntu-22_04-x86_64), 2026-09-26 +ADD --chmod=0755 \ + --checksum=sha256:5afb1d25efe5ccf564a1ab124bc7f410a7a73171c974a4d8b7f4d8f2e3d62e77 \ + https://github.com/simplex-chat/simplex-chat/releases/download/v7.0.2/simplex-chat-ubuntu-22_04-x86_64 \ + /usr/local/bin/simplex-chat # Create non-root user -RUN addgroup -g 1000 -S simplexcalc && \ - adduser -u 1000 -S simplexcalc -G simplexcalc +RUN groupadd --gid 1000 simplexcalc && \ + useradd --uid 1000 --gid simplexcalc --home-dir /var/lib/simplexcalc \ + --no-create-home --shell /usr/sbin/nologin simplexcalc WORKDIR /app # Copy binary from builder COPY --from=builder /build/bin/simplexcalc /app/simplexcalc -# Data directory: the sqlite database lives here. Mount a volume over -# it in production; everything else in the image is read-only. -RUN mkdir -p /var/lib/simplexcalc - -RUN chown -R simplexcalc:simplexcalc /app /var/lib/simplexcalc +# Data directory: the SimpleX database, which holds the bot's profile, +# its keys, its address and its contacts. Mount a volume over it; +# without one, the bot gets a new address every time the container is +# recreated. +RUN mkdir -p /var/lib/simplexcalc && \ + chown simplexcalc:simplexcalc /var/lib/simplexcalc USER simplexcalc ENV DATA_DIR=/var/lib/simplexcalc -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck.json || exit 1 - -CMD ["/app/simplexcalc", "serve"] +CMD ["/app/simplexcalc", "run"] diff --git a/Makefile b/Makefile index e6f8d80..f37c66d 100644 --- a/Makefile +++ b/Makefile @@ -31,11 +31,13 @@ check: build: go build -o bin/simplexcalc ./cmd/simplexcalc +# run and dev need the simplex-chat command-line client on PATH; the +# image built by `make docker` carries its own. run: build - ./bin/simplexcalc serve + ./bin/simplexcalc run dev: - go run ./cmd/simplexcalc serve + go run ./cmd/simplexcalc run deps: go mod download diff --git a/README.md b/README.md index 5970288..80aa58b 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,198 @@ simplexcalc is a Go chat bot by [@sneak](https://sneak.berlin) for the SimpleX Chat network: it accepts every contact request and answers arithmetic such as `2 + 2` with the result. + +Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies +`12.5`. It understands decimal numbers, `+ - * /`, unary minus and +parentheses, and computes exactly, so `0.1 + 0.2` is `0.3`. Anything +else gets a short explanation instead of a result. + +## Getting Started + +Build the image and run the bot, with its SimpleX profile on a named +volume: + +```sh +git clone git@git.eeqj.de:clawbot/simplexcalc.git +cd simplexcalc +make docker +docker run -d --name simplexcalc --restart unless-stopped \ + -v simplexcalc-data:/var/lib/simplexcalc simplexcalc +docker logs simplexcalc 2>&1 | grep '"msg":"ready"' +``` + +The `ready` log line carries the bot's contact address: `address` is the +short link to share, and `full_address` is the same address in the long +form that older SimpleX clients need. Open the link in any SimpleX Chat +app, or paste it into its "connect via link" screen; the bot accepts at +once, greets you, and answers every message you send it. + +The address is stored on the `simplexcalc-data` volume. It survives +restarts, image upgrades and recreating the container, and is lost only +with the volume. + +### Messaging it from a terminal + +The image carries the SimpleX Chat command-line client, so a throwaway +second client can talk to the bot without installing anything. Replace +`ADDRESS` with the bot's address: + +```sh +docker run --rm -v simplexcalc-tester:/var/lib/simplexcalc simplexcalc \ + simplex-chat -d /var/lib/simplexcalc/tester --user-display-name tester \ + -e "/c ADDRESS" -t 30 --execute-log all +docker run --rm -v simplexcalc-tester:/var/lib/simplexcalc simplexcalc \ + simplex-chat -d /var/lib/simplexcalc/tester \ + -e "@calc 5 * 5/2" -t 20 --execute-log messages +docker volume rm simplexcalc-tester +``` + +The first command connects and prints the bot's greeting; the second +sends `5 * 5/2` and prints the reply, `12.5`. + +## Configuration + +All configuration is environment variables, read once at startup. A +`.env` file in the working directory is loaded automatically for +development. + +**A variable that is set to something unparseable aborts startup.** It +is never quietly replaced by the default. Defaults apply only to +variables that are absent. + +- `DATA_DIR` — where the SimpleX database lives: the bot's profile, its + keys, its address and its contacts. Default `./data`; the image sets + `/var/lib/simplexcalc`. +- `DEBUG` — `true` or `false`, default `false`. `true` logs every event + the chat client sends. + +## Entrypoints + +This repo adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard. The `script/` entrypoints, each with a thin `make` shim: + +- `script/bootstrap` (`make bootstrap`) — install build dependencies + (git, make, go) idempotently. Linting additionally needs docker; + markdown formatting needs docker or a local `prettier`. +- `script/setup` (`make setup`) — `bootstrap` plus the git pre-commit + hook +- `script/test` (`make test`) — `go test` with the race detector and + coverage, `-count=1`, 90 s timeout; quiet on success, verbose rerun on + failure +- `script/lint` (`make lint`) — `golangci-lint` via docker only, against + the digest-pinned image, then `script/assert-step-ran` and + `script/assert-context-complete` over the build log. Nothing is + installed locally and nothing runs on the host. +- `script/fmt` (`make fmt`) — format Go with `gofmt` and everything else + with `prettier` (writes) +- `script/fmt-check` (`make fmt-check`) — the same scope, read-only +- `script/check` (`make check`) — `test` + `lint` + `fmt-check`. What + the pre-commit hook runs. Never modifies files. +- `script/docker` (`make docker`) — build the image, tagged with + `script/projectname` +- `script/cibuild` (`make cibuild`) — the CI gate: + `docker build --progress=plain --no-cache-filter=lint --no-cache-filter=builder .` + followed by assertions that the lint step and the test step really + ran, and that both stages really received the whole repository. The + Gitea workflow runs this on every push. +- `script/precommit` — the hook body: `go mod tidy` must not change + `go.mod`/`go.sum`, then `check` +- `script/install-precommit` (`make hooks`) — installs the hook +- `script/projectname` — prints the project name; other scripts call it + so they can stay identical across repos +- `script/prettier`, `script/assert-step-ran`, + `script/assert-context-complete`, `script/repo-source-manifest` — + helpers, not entrypoints + +`make build`, `make run`, `make dev`, `make deps` and `make clean` are +the ordinary conveniences. `make run` and `make dev` run the bot on the +host, which needs the `simplex-chat` command-line client on `PATH`. + +### Why the assertions exist + +A green `docker build` is not evidence that the checks ran. On an +unchanged tree every layer comes from cache and the build exits 0 having +executed nothing; BuildKit silently ignores a `--no-cache-filter` naming +a stage that no longer exists; and a `.dockerignore` entry can remove a +package from the build context, after which the linter genuinely runs, +genuinely examines what it was handed, and genuinely reports `0 issues.` +over a repository with a violation in it. + +So the build is not trusted. `script/assert-step-ran` requires the log +to show the step executing and the tool's own success line coming out of +it. `script/assert-context-complete` requires the file inventory each +stage emitted to match the git index — an expectation `.dockerignore` +cannot reach. Read the comments in those two scripts before changing +either; they document what they still cannot see. + +## Rationale + +SimpleX Chat is an end-to-end encrypted messenger with no user +identifiers: people connect through addresses they choose to share. +simplexcalc is a calculator anyone can reach that way, and a small, +complete example of a SimpleX bot in Go: profile and address set-up, +automatic acceptance, and replies, with the chat client in the same +container. + +## Design + +- **One process tree, one container.** `simplexcalc run` starts the + SimpleX Chat command-line client, `simplex-chat`, as a child process, + with its database under `$DATA_DIR/simplex` and its WebSocket API on + `127.0.0.1:5225`. The API has no authentication, which is why it is + never exposed outside the container. +- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a + command carries a correlation id, its response carries the same id, + and anything without one is an event. Only the fields the bot reads + are decoded, so records that grow new fields in a later client release + still decode. +- **Set-up on every start** (`internal/bot`): read the bot's profile, + create its long-term address if it has none, and set the address to + accept every contact request and to greet each new contact. The first + start creates the profile itself, a bot profile named `calc`. The + address is logged in the `ready` line. +- **Replies**: for each text message a contact sends in a direct chat, + the bot sends back the result, as a reply quoting the message. Group + messages, files and the bot's own messages are ignored. +- **Arithmetic** (`internal/calc`): the text is parsed as a Go + expression with `go/parser`, and only numbers, `+ - * /`, unary signs + and parentheses are evaluated; anything else in the syntax tree is + refused. `go/constant` computes with exact rationals. Numbers are read + as decimal, so `010` is ten. Input over 256 bytes is refused, so a + message cannot make the bot do unbounded work. Whole numbers below + 1021 are written exactly; other results in the shortest + form that reads back as the same double, in exponent notation from + 1021 up and below 10-6. A result beyond the + range of a double is refused as too large. +- **Failure is an exit.** If the chat client exits or the connection to + it drops, the bot exits with an error and the container's restart + policy starts both again. `SIGTERM` stops the bot, which stops the + chat client with `SIGTERM` and kills it if it has not exited within 10 + seconds. +- **The image**: a Go build stage, then an Ubuntu 22.04 runtime — the + release the chat client is built for, which already has every library + it links — with the `simplex-chat` v7.0.2 binary downloaded by an + `ADD` whose checksum BuildKit verifies. It runs as an unprivileged + user and is x86_64 only. + +## Operating it + +**Backup.** Everything durable is the SimpleX database on the volume: +`simplex_chat.db` and `simplex_agent.db`. Stop the container before +copying them, since a copy taken from under the running client can be +inconsistent. The database holds the bot's keys, so a copy lets its +holder answer as the bot; keep it as private as the running instance. + +**Upgrade.** Rebuild the image and recreate the container with the same +volume. The chat client migrates its database on start. A newer +`simplex-chat` release is a change to the URL and the checksum on the +`ADD` line in the `Dockerfile`. + +## TODO + +See `docs/TODO.md` and the repository's issue tracker. + +## Author + +[@sneak](https://sneak.berlin) diff --git a/cmd/simplexcalc/main.go b/cmd/simplexcalc/main.go index ee0c325..ff0dbed 100644 --- a/cmd/simplexcalc/main.go +++ b/cmd/simplexcalc/main.go @@ -1,19 +1,12 @@ -// Command simplexcalc is the service entrypoint. -// -// Seeding a new project: script/rename renames this directory, the -// module path and the binary. Nothing here needs editing by hand. +// Command simplexcalc is a SimpleX Chat bot that answers arithmetic. package main import ( "fmt" "os" - "runtime" - - "sneak.berlin/go/simplexcalc/internal/globals" ) -// appname is the name the service reports in logs, metrics and the -// healthcheck. +// appname is the name the program reports in its logs and its version. const appname = "simplexcalc" // version is injected at build time with -ldflags "-X main.version=...". @@ -23,10 +16,6 @@ const appname = "simplexcalc" var version = "dev" func main() { - globals.Appname = appname - globals.Version = version - globals.Buildarch = runtime.GOARCH - err := rootCmd().Execute() if err != nil { // cobra has already printed the error; this only sets the exit diff --git a/cmd/simplexcalc/root.go b/cmd/simplexcalc/root.go index 8fa2690..42e4bc6 100644 --- a/cmd/simplexcalc/root.go +++ b/cmd/simplexcalc/root.go @@ -8,15 +8,16 @@ import ( ) // rootCmd builds the command tree. Configuration comes from the -// environment (see internal/config), not from flags: the service runs -// in containers, where the environment is the interface, and one source -// of truth means there is no precedence rule to get wrong. +// environment (see internal/config), not from flags: the bot runs in a +// container, where the environment is the interface, and one source of +// truth means there is no precedence rule to get wrong. func rootCmd() *cobra.Command { root := &cobra.Command{ Use: appname, - Short: appname + " — an HTTP service", - Long: appname + " is an HTTP service.\n\n" + - "Configuration is read from the environment and from an\n" + + Short: appname + " — a SimpleX Chat bot that answers arithmetic", + Long: appname + " is a SimpleX Chat bot that answers arithmetic.\n\n" + + "It runs the simplex-chat command-line client, which must be on\n" + + "PATH. Configuration is read from the environment and from an\n" + "optional .env file in the working directory. See README.md\n" + "for the full list of variables.", SilenceUsage: true, @@ -25,7 +26,7 @@ func rootCmd() *cobra.Command { SilenceErrors: true, } - root.AddCommand(serveCmd(), versionCmd()) + root.AddCommand(runCmd(), versionCmd()) return root } diff --git a/cmd/simplexcalc/run.go b/cmd/simplexcalc/run.go new file mode 100644 index 0000000..5616b19 --- /dev/null +++ b/cmd/simplexcalc/run.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "runtime" + "syscall" + + "github.com/spf13/cobra" + "sneak.berlin/go/simplexcalc/internal/bot" + "sneak.berlin/go/simplexcalc/internal/config" + "sneak.berlin/go/simplexcalc/internal/logger" +) + +func runCmd() *cobra.Command { + return &cobra.Command{ + Use: "run", + Short: "run the bot", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd.Context()) + }, + } +} + +// run reads the configuration and runs the bot until SIGINT or SIGTERM, +// or until it fails. A failure is the process's exit status, so a +// supervisor restarts it. +func run(ctx context.Context) error { + cfg, err := config.New() + if err != nil { + return fmt.Errorf("reading configuration: %w", err) + } + + log := logger.New(os.Stdout, cfg.Debug) + log.Info("starting", + "appname", appname, + "version", version, + "arch", runtime.GOARCH, + "data_dir", cfg.DataDir, + ) + + ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer stop() + + err = bot.Run(ctx, log, cfg.DataDir) + if err != nil { + log.Error("stopped", "error", err) + + return err + } + + log.Info("stopped") + + return nil +} diff --git a/cmd/simplexcalc/serve.go b/cmd/simplexcalc/serve.go deleted file mode 100644 index a20a7df..0000000 --- a/cmd/simplexcalc/serve.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "context" - "fmt" - "time" - - "github.com/spf13/cobra" - "go.uber.org/fx" - "sneak.berlin/go/simplexcalc/internal/app" -) - -// startTimeout bounds startup: opening the database, applying -// migrations and binding the port. A start that hangs past this is a -// start that failed, and saying so beats waiting forever. -const startTimeout = 30 * time.Second - -// stopTimeout bounds the whole stop sequence. The server's own drain is -// bounded by SHUTDOWN_GRACE within this; the margin covers closing the -// database and flushing Sentry after the drain finishes. -const stopTimeout = 45 * time.Second - -func serveCmd() *cobra.Command { - return &cobra.Command{ - Use: "serve", - Short: "run the HTTP server", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return serve(cmd.Context()) - }, - } -} - -// serve builds the graph and runs until a signal arrives. -// -// fx.App.Run handles SIGINT and SIGTERM and then runs the stop -// sequence, which is why nothing here installs a signal handler: two -// handlers for the same signal is how a shutdown ends up half done. -func serve(ctx context.Context) error { - fxApp := app.New( - fx.StartTimeout(startTimeout), - fx.StopTimeout(stopTimeout), - ) - - // A construction error is reported here rather than by Run, which - // would exit the process itself and give the caller nothing to - // report. Configuration failures arrive through this path. - err := fxApp.Err() - if err != nil { - return fmt.Errorf("building application: %w", err) - } - - startCtx, cancel := context.WithTimeout(ctx, startTimeout) - defer cancel() - - err = fxApp.Start(startCtx) - if err != nil { - return fmt.Errorf("starting application: %w", err) - } - - // Block until a signal. fx closes this channel after it receives - // one; the stop sequence is ours to run. - <-fxApp.Wait() - - // Deliberately NOT derived from ctx: by the time this runs, the - // signal that ends the process has already cancelled it, and a - // stop context that is cancelled from the outset gives the drain - // no time at all — which is the abrupt termination the graceful - // shutdown exists to avoid. - stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout) - defer stopCancel() - - //nolint:contextcheck // see above: the fresh context is the point. - err = fxApp.Stop(stopCtx) - if err != nil { - return fmt.Errorf("stopping application: %w", err) - } - - return nil -} diff --git a/docs/TODO.md b/docs/TODO.md index 70b329b..970fe8a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -10,44 +10,29 @@ # Status -pre-1.0. No git tags exist. `main` is a working HTTP service that -builds, tests and lints clean: cobra command tree, `fx` object graph, -viper configuration that aborts on an unparseable value, sqlite with an -embedded schema and a migration runner, embedded templates and static -assets, the full middleware chain (request id, logging, metrics, panic -recovery, request timeout, body cap, security headers, CSRF), Sentry, -and Prometheus `/metrics` behind optional basic auth. - -This repository is a template. A project seeded from it should replace -this Status section and everything below it, keeping the Workflow -section above unchanged. +pre-1.0. No git tags exist. `main` is a working bot: one container image +holding the bot and the SimpleX Chat command-line client v7.0.2, a bot +profile named `calc` with a long-term address that accepts every contact +request, and exact arithmetic replies (`+ - * /`, parentheses, +decimals). The Gitea issue tracker is the authoritative list of work; +this file mirrors it. # Next Step -Replace `gomodguard` with `gomodguard_v2` in `.golangci.yml`. -golangci-lint v2.12.2 emits a deprecation warning for it on every run -(`the linter 'gomodguard' is deprecated (since v2.12.0) ... Replaced by gomodguard_v2`). -Neither is configured with rules here, so the change is to the `linters` -block only; done when `make lint` runs clean with no deprecation warning -in the output. +Adopt the canonical `.golangci.yml` from the `prompts` repository once +it replaces `gomodguard` with `gomodguard_v2`. golangci-lint v2.12.2 +warns on every run that `gomodguard` is deprecated. The file is +maintained upstream and is not edited here; done when `make lint` runs +with no deprecation warning. # Completed Steps -- 2026-08-22 Built the template out from an empty repository: STRTA - `script/` entrypoints with `Makefile` shims, `Dockerfile` and - `Dockerfile.lint` on digest-pinned bases, Gitea workflow running - `script/cibuild`, `.golangci.yml`, `.editorconfig`, `.prettierrc`, - `docs/REPO_POLICIES.md`, `AGENTS.md`, the HTTP service and its tests, - and `script/rename` for seeding +- 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service + removed) and built the bot: `internal/calc` for the arithmetic, + `internal/simplex` for the chat client process and its WebSocket API, + `internal/bot` for address setup and replies, and a runtime image on + Ubuntu 22.04 carrying the checksum-verified `simplex-chat` binary # Future Steps -- Add a `docker-compose.yml` showing the service behind a - TLS-terminating reverse proxy with `X-Forwarded-Proto` set, since that - is the deployment shape the CSRF middleware is written for and the one - an operator is most likely to get wrong -- Add a `script/release` that tags, builds with `VERSION` set, and - pushes the image, so `globals.Version` is something other than `dev` - in a real deployment -- Decide whether the template should ship a session/auth layer or stay - deliberately without one +none diff --git a/go.mod b/go.mod index e193ae0..f0c9abc 100644 --- a/go.mod +++ b/go.mod @@ -3,49 +3,29 @@ module sneak.berlin/go/simplexcalc go 1.25.0 require ( - github.com/dustin/go-humanize v1.0.1 - github.com/getsentry/sentry-go v0.48.0 - github.com/go-chi/chi/v5 v5.3.1 - github.com/google/uuid v1.6.0 - github.com/gorilla/csrf v1.7.3 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 - github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - go.uber.org/fx v1.24.0 - modernc.org/sqlite v1.56.0 ) require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/dig v1.19.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.26.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.37.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect - modernc.org/libc v1.74.4 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index e239f35..1fd508c 100644 --- a/go.sum +++ b/go.sum @@ -1,74 +1,31 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= -github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= -github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= -github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= -github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= -github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -91,62 +48,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= -go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= -go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= -go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= -modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= -modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= -modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 4eda63d..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package app wires the object graph. It is the one place that knows -// which concrete types satisfy the application's dependencies, so every -// other package can be constructed in a test with substitutes. -package app - -import ( - "log/slog" - - "go.uber.org/fx" - "go.uber.org/fx/fxevent" - "sneak.berlin/go/simplexcalc/internal/config" - "sneak.berlin/go/simplexcalc/internal/database" - "sneak.berlin/go/simplexcalc/internal/globals" - "sneak.berlin/go/simplexcalc/internal/handlers" - "sneak.berlin/go/simplexcalc/internal/logger" - "sneak.berlin/go/simplexcalc/internal/middleware" - "sneak.berlin/go/simplexcalc/internal/render" - "sneak.berlin/go/simplexcalc/internal/server" - "sneak.berlin/go/simplexcalc/internal/telemetry" -) - -// Module is every provider the service needs. Constructor order is -// irrelevant to fx; the order here is the order a reader wants: build -// metadata, logging, configuration, storage, then the HTTP layer. -// -//nolint:gochecknoglobals // an fx module is a declaration, not mutable state. -var Module = fx.Options( - fx.Provide( - globals.New, - logger.New, - config.New, - database.New, - telemetry.NewSentry, - telemetry.NewMetrics, - render.New, - middleware.New, - handlers.New, - server.New, - ), - - // fx's own lifecycle events go through the application logger, so - // the process emits one stream in one format. Without this, fx - // prints its own plain-text output to stderr and a log pipeline - // gets two formats from one process. - fx.WithLogger(func(l *logger.Logger) fxevent.Logger { - return &fxevent.SlogLogger{Logger: l.Get()} - }), -) - -// Invoke forces the graph to be built. fx constructs lazily: without a -// request for the Server, a perfectly valid App would start, construct -// nothing, and serve nothing. -// -//nolint:gochecknoglobals // as above. -var Invoke = fx.Invoke(func(_ *server.Server, log *logger.Logger, cfg *config.Config) { - if cfg.Debug { - log.EnableDebugLogging() - } - - log.Identify() -}) - -// New builds the fx application for `serve`. -func New(opts ...fx.Option) *fx.App { - return fx.New(append([]fx.Option{Module, Invoke}, opts...)...) -} - -// DiscardLogger is a logger that writes nothing, for tests that build -// the graph and do not want its startup output. -func DiscardLogger() *slog.Logger { - return slog.New(slog.DiscardHandler) -} diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..4ba2eb0 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,230 @@ +// Package bot is the calculator: it runs the SimpleX Chat client, makes +// sure the bot has a contact address that accepts everyone, and answers +// every text message with the value of the arithmetic in it. +package bot + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "time" + + "sneak.berlin/go/simplexcalc/internal/calc" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// DisplayName is the name of the bot's SimpleX profile, given to it +// when the profile is created on the first start. +const DisplayName = "calc" + +// Welcome is sent to everyone whose contact request the bot accepts. +const Welcome = "Send me arithmetic, such as 2 + 2 or 5 * 5/2, " + + "and I will reply with the result." + +const ( + // chatPort is where the chat client serves its API, on localhost + // inside the bot's own container. + chatPort = 5225 + + // connectTimeout bounds the wait for a freshly started chat client + // to open its API, which includes creating or migrating the + // database. + connectTimeout = 60 * time.Second + + // setupTimeout bounds each setup command. Creating an address + // talks to SimpleX relays over the network. + setupTimeout = 2 * time.Minute + + // retryInterval paces the connection attempts. + retryInterval = 250 * time.Millisecond + + dataDirMode = 0o700 +) + +var errExited = errors.New("simplex-chat exited") + +// Run starts the chat client with its database in dataDir, connects to +// it, sets up the bot's address, and answers messages until ctx is +// cancelled — which is a clean stop and returns nil — or until the chat +// client or the connection to it fails, which returns the error. +func Run(ctx context.Context, log *slog.Logger, dataDir string) error { + err := os.MkdirAll(dataDir, dataDirMode) + if err != nil { + return fmt.Errorf("creating data directory: %w", err) + } + + // Cancelling this stops the chat client; the deferred wait makes + // Run return only once it has exited, whatever path Run takes. + cliCtx, stopCLI := context.WithCancel(ctx) + + cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(dataDir, "simplex"), + DisplayName, chatPort) + if err != nil { + stopCLI() + + return err + } + + defer func() { + stopCLI() + <-cli.Done() + }() + + client, err := connect(ctx, log, cli) + if err != nil { + return err + } + + defer func() { _ = client.Close() }() + + err = setUp(ctx, log, client) + if err != nil { + return err + } + + select { + case <-ctx.Done(): + return nil + case <-client.Done(): + return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err()) + case <-cli.Done(): + return fmt.Errorf("%w: %w", errExited, cli.Err()) + } +} + +// connect waits for the chat client to open its API and connects to it. +func connect( + ctx context.Context, log *slog.Logger, cli *simplex.CLI, +) (*simplex.Client, error) { + ctx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + + url := "ws://127.0.0.1:" + strconv.Itoa(chatPort) + + for { + client, err := simplex.Dial(ctx, url, log, handle(log)) + if err == nil { + return client, nil + } + + select { + case <-cli.Done(): + return nil, fmt.Errorf("%w before opening its API: %w", errExited, cli.Err()) + case <-ctx.Done(): + return nil, fmt.Errorf("waiting for simplex-chat: %w (last error: %w)", + ctx.Err(), err) + case <-time.After(retryInterval): + } + } +} + +// setUp gives the bot a long-term contact address, creating it on the +// first start, and sets it to accept every contact request and to greet +// each new contact. The settings are written on every start, so an +// address whose settings were changed by hand is put right. +func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error { + ctx, cancel := context.WithTimeout(ctx, setupTimeout) + defer cancel() + + user, err := client.ActiveUser(ctx) + if err != nil { + return fmt.Errorf("reading the bot's profile: %w", err) + } + + link, ok, err := client.Address(ctx, user.UserID) + if err != nil { + return fmt.Errorf("reading the bot's address: %w", err) + } + + if !ok { + log.Info("creating the bot's address") + + link, err = client.CreateAddress(ctx, user.UserID) + if err != nil { + return fmt.Errorf("creating the bot's address: %w", err) + } + } + + err = client.SetAddressSettings(ctx, user.UserID, simplex.AddressSettings{ + AutoAccept: &simplex.AutoAccept{AcceptIncognito: false}, + AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome}, + }) + if err != nil { + return fmt.Errorf("setting the bot's address to accept everyone: %w", err) + } + + log.Info("ready", + "display_name", user.Profile.DisplayName, + "address", link.ShortLink, + "full_address", link.FullLink, + ) + + return nil +} + +// handle answers each text message a contact sends. +func handle(log *slog.Logger) simplex.EventHandler { + return func(c *simplex.Client, ev simplex.Event) { + switch ev.Type { + case simplex.TypeNewChatItems: + var r simplex.NewChatItems + + err := ev.Decode(&r) + if err != nil { + log.Warn("ignoring an event", "error", err) + + return + } + + for _, item := range r.ChatItems { + msg, ok := item.Message() + if !ok { + continue + } + + err = c.SendText(msg.ContactID, msg.ItemID, Reply(msg.Text)) + if err != nil { + log.Error("replying", "contact_id", msg.ContactID, "error", err) + } + } + case simplex.TypeContactConnected: + var r simplex.ContactConnected + + err := ev.Decode(&r) + if err != nil { + log.Warn("ignoring an event", "error", err) + + return + } + + log.Info("contact connected", "contact_id", r.Contact.ContactID) + default: + log.Debug("event", "type", ev.Type) + } + } +} + +// Reply is the bot's answer to a message: the value of the arithmetic +// in it, or a short explanation of why there is none. +func Reply(text string) string { + result, err := calc.Evaluate(text) + + switch { + case err == nil: + return result + case errors.Is(err, calc.ErrTooLong): + return fmt.Sprintf("That is too long for me: at most %d characters, please.", + calc.MaxInputLength) + case errors.Is(err, calc.ErrDivisionByZero): + return "I cannot divide by zero." + case errors.Is(err, calc.ErrTooLarge): + return "The result is too large for me." + default: + return "I only understand arithmetic: numbers, + - * / and " + + "parentheses, such as 5 * 5/2." + } +} diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go new file mode 100644 index 0000000..3f7971e --- /dev/null +++ b/internal/bot/bot_test.go @@ -0,0 +1,35 @@ +package bot_test + +import ( + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/bot" + "sneak.berlin/go/simplexcalc/internal/calc" +) + +// TestReply: a result is sent bare, and every way of failing gets its +// own short explanation rather than silence. +func TestReply(t *testing.T) { + t.Parallel() + + for in, want := range map[string]string{ + "2 + 2": "4", + "5 * 5/2": "12.5", + } { + if got := bot.Reply(in); got != want { + t.Errorf("Reply(%q) = %q, want %q", in, got, want) + } + } + + for in, want := range map[string]string{ + "hello": "I only understand arithmetic", + "1 / 0": "I cannot divide by zero.", + "1e400": "The result is too large for me.", + strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me", + } { + if got := bot.Reply(in); !strings.HasPrefix(got, want) { + t.Errorf("Reply(%q) = %q, want it to start %q", in, got, want) + } + } +} diff --git a/internal/calc/calc.go b/internal/calc/calc.go new file mode 100644 index 0000000..2f2bd65 --- /dev/null +++ b/internal/calc/calc.go @@ -0,0 +1,181 @@ +// Package calc evaluates the arithmetic people send the bot: decimal +// numbers, + - * /, unary minus and parentheses. +// +// The expression is parsed by go/parser and computed by go/constant, +// which does exact rational arithmetic: 5 * 5/2 is exactly 12.5, and +// 0.1 + 0.2 is exactly 0.3, so a result carries no binary floating +// point noise until the moment it is formatted. +package calc + +import ( + "errors" + "go/ast" + "go/constant" + "go/parser" + "go/token" + "math" + "regexp" + "strconv" + "strings" +) + +// MaxInputLength caps an expression, in bytes, so a message cannot make +// the bot do unbounded work. Every operation's cost grows with the size +// of its operands, and the operands can only grow with the input. +const MaxInputLength = 256 + +// Results of magnitude plainUpper or more are written in exponent form +// (1e+21 rather than twenty-two digits), and so are fractions smaller +// than plainLower (1e-07 rather than 0.0000001). +const ( + plainUpper = 1e21 + plainLower = 1e-6 +) + +// Errors returned by Evaluate. The bot turns each into a reply. +var ( + ErrTooLong = errors.New("expression too long") + ErrNotArithmetic = errors.New("not an arithmetic expression") + ErrDivisionByZero = errors.New("division by zero") + ErrTooLarge = errors.New("result too large") +) + +// decimalLiteral is the only number syntax accepted. Go's own literal +// syntax is wider, and parts of it are traps for someone typing +// arithmetic: 010 is octal 8, and 0x10, 1_000 and 1i are not what a +// calculator user means by a number. +var decimalLiteral = regexp.MustCompile( + `^([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`, +) + +// Evaluate computes an arithmetic expression and returns its result as +// text: whole numbers without a decimal point, fractions in the +// shortest form that reads back as the same float64. +func Evaluate(input string) (string, error) { + s := strings.TrimSpace(input) + if len(s) > MaxInputLength { + return "", ErrTooLong + } + + if s == "" { + return "", ErrNotArithmetic + } + + expr, err := parser.ParseExpr(s) + if err != nil { + return "", ErrNotArithmetic + } + + v, err := eval(expr) + if err != nil { + return "", err + } + + return format(v) +} + +// eval walks the syntax tree, allowing only the node types and +// operators of arithmetic. Anything else — identifiers, calls, strings, +// shifts, comparisons — is refused, not evaluated. +func eval(e ast.Expr) (constant.Value, error) { + switch n := e.(type) { + case *ast.BasicLit: + return literal(n) + case *ast.ParenExpr: + return eval(n.X) + case *ast.UnaryExpr: + if n.Op != token.ADD && n.Op != token.SUB { + return nil, ErrNotArithmetic + } + + x, err := eval(n.X) + if err != nil { + return nil, err + } + + return constant.UnaryOp(n.Op, x, 0), nil + case *ast.BinaryExpr: + return binary(n) + default: + return nil, ErrNotArithmetic + } +} + +func binary(n *ast.BinaryExpr) (constant.Value, error) { + switch n.Op { //nolint:exhaustive // every other operator is refused. + case token.ADD, token.SUB, token.MUL, token.QUO: + default: + return nil, ErrNotArithmetic + } + + x, err := eval(n.X) + if err != nil { + return nil, err + } + + y, err := eval(n.Y) + if err != nil { + return nil, err + } + + // constant.BinaryOp panics on a zero divisor. + if n.Op == token.QUO && constant.Sign(y) == 0 { + return nil, ErrDivisionByZero + } + + // token.QUO divides exactly, integers included: 25/2 is 12.5. + v := constant.BinaryOp(x, n.Op, y) + + // go/constant represents an overflow to infinity as Unknown. + if v.Kind() == constant.Unknown { + return nil, ErrTooLarge + } + + return v, nil +} + +func literal(n *ast.BasicLit) (constant.Value, error) { + if n.Kind != token.INT && n.Kind != token.FLOAT { + return nil, ErrNotArithmetic + } + + if !decimalLiteral.MatchString(n.Value) { + return nil, ErrNotArithmetic + } + + // Read as FLOAT whatever the token says, which makes every literal + // decimal: as INT, a leading zero would make it octal. + v := constant.MakeFromLiteral(n.Value, token.FLOAT, 0) + + // The syntax was checked above, so Unknown here means the exponent + // overflowed. + if v.Kind() == constant.Unknown { + return nil, ErrTooLarge + } + + return v, nil +} + +// format writes a result for a person to read. A whole number of +// ordinary size is written exactly, digit for digit; anything else goes +// through float64, whose shortest round-trip form is free of the noise +// (0.30000000000000004) that printing a binary fraction to a fixed +// precision produces. +func format(v constant.Value) (string, error) { + f, _ := constant.Float64Val(v) + if math.IsInf(f, 0) || math.IsNaN(f) { + return "", ErrTooLarge + } + + abs := math.Abs(f) + + if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper { + return i.ExactString(), nil + } + + if abs >= plainUpper || abs < plainLower { + return strconv.FormatFloat(f, 'g', -1, 64), nil + } + + return strconv.FormatFloat(f, 'f', -1, 64), nil +} diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go new file mode 100644 index 0000000..9b91d97 --- /dev/null +++ b/internal/calc/calc_test.go @@ -0,0 +1,150 @@ +package calc_test + +import ( + "errors" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/calc" +) + +// TestEvaluate covers the two examples the bot was specified with, and +// the arithmetic around them. +func TestEvaluate(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + // The specification's own examples. + "2 + 2": "4", + "5 * 5/2": "12.5", + + "2+2": "4", + " 7 - 10 \n": "-3", + "-3 * 2": "-6", + "+4": "4", + "-(-4)": "4", + "(1 + 2) * 3": "9", + "1 + 2 * 3": "7", + "((2))": "2", + "8 / 2 / 2": "2", + "10 - 2 - 3": "5", + "7 / 2": "3.5", + "25/2": "12.5", + "1 / 3": "0.3333333333333333", + "2 / 3": "0.6666666666666666", + "0.1 + 0.2": "0.3", + "1.5 * 2": "3", + "2.50 * 2": "5", + ".5 + .5": "1", + "3. * 2": "6", + "1e3 + 1": "1001", + "2.5e-1": "0.25", + "010 + 1": "11", + "-0": "0", + "0 / 5": "0", + // Exact: a float64 would print 99999999980000000000. + "9999999999 * 9999999999": "99999999980000000001", + "1e21": "1e+21", + "1e20": "100000000000000000000", + "1 / 1e7": "1e-07", + "1 / 1e6": "0.000001", + "1234567.5": "1234567.5", + "-1 / 4": "-0.25", + "1e300 * 1e8": "1e+308", + } + + for in, want := range cases { + t.Run(in, func(t *testing.T) { + t.Parallel() + + got, err := calc.Evaluate(in) + if err != nil { + t.Fatalf("Evaluate(%q) failed: %v", in, err) + } + + if got != want { + t.Errorf("Evaluate(%q) = %q, want %q", in, got, want) + } + }) + } +} + +// TestEvaluateRefuses covers what must be answered with an error rather +// than a number, and never with a panic. +func TestEvaluateRefuses(t *testing.T) { + t.Parallel() + + cases := map[string]error{ + "": calc.ErrNotArithmetic, + " ": calc.ErrNotArithmetic, + "hello": calc.ErrNotArithmetic, + "/help": calc.ErrNotArithmetic, + "2 +": calc.ErrNotArithmetic, + "2 2": calc.ErrNotArithmetic, + "2 + 2 =": calc.ErrNotArithmetic, + "x + 1": calc.ErrNotArithmetic, + "len(\"abc\")": calc.ErrNotArithmetic, + "\"a\" + \"b\"": calc.ErrNotArithmetic, + "'a' + 1": calc.ErrNotArithmetic, + "2i * 2i": calc.ErrNotArithmetic, + "0x10 + 1": calc.ErrNotArithmetic, + "1_000 + 1": calc.ErrNotArithmetic, + "7 % 2": calc.ErrNotArithmetic, + "2 ^ 3": calc.ErrNotArithmetic, + "1 << 10": calc.ErrNotArithmetic, + "1 == 1": calc.ErrNotArithmetic, + "!1": calc.ErrNotArithmetic, + "func() int { return 1 }()": calc.ErrNotArithmetic, + "1 / 0": calc.ErrDivisionByZero, + "1 / (2 - 2)": calc.ErrDivisionByZero, + "5 / 0.0": calc.ErrDivisionByZero, + "1e400": calc.ErrTooLarge, + "1e300 * 1e300": calc.ErrTooLarge, + "1e999999999 * 1e999999999": calc.ErrTooLarge, + "1 / 1e-400": calc.ErrTooLarge, + } + + for in, want := range cases { + t.Run(in, func(t *testing.T) { + t.Parallel() + + got, err := calc.Evaluate(in) + if !errors.Is(err, want) { + t.Errorf("Evaluate(%q) = %q, %v; want error %v", in, got, err, want) + } + }) + } +} + +// TestEvaluateCapsInput: the length cap is what bounds the work a +// message can cause, so it must hold exactly at the boundary. +func TestEvaluateCapsInput(t *testing.T) { + t.Parallel() + + // "1+1+...+1" with the last term padded to land exactly on the cap. + longest := strings.Repeat("1+", calc.MaxInputLength/2-1) + "10" + if len(longest) != calc.MaxInputLength { + t.Fatalf("test setup: expression is %d bytes, want %d", + len(longest), calc.MaxInputLength) + } + + got, err := calc.Evaluate(longest) + if err != nil { + t.Fatalf("an expression of exactly MaxInputLength bytes was refused: %v", err) + } + + if want := "137"; got != want { + t.Errorf("Evaluate(longest) = %q, want %q", got, want) + } + + _, err = calc.Evaluate(longest + "0") + if !errors.Is(err, calc.ErrTooLong) { + t.Errorf("an expression over MaxInputLength gave %v, want ErrTooLong", err) + } + + // Surrounding whitespace is not part of the expression. + _, err = calc.Evaluate(" " + longest + "\n") + if err != nil { + t.Errorf("whitespace around a maximal expression counted against the cap: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8c30e92..7e321d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,34 +3,30 @@ // // The iron rule of this package: a value that is SET but cannot be // parsed aborts startup. It is never replaced by the default. An -// operator who writes PORT=eighty has said something specific and -// wrong, and starting anyway on port 8080 turns their mistake into a -// silent misconfiguration that only surfaces much later, somewhere -// else. Defaults apply to values that are ABSENT, and to nothing else. +// operator who writes DEBUG=yes has said something specific that this +// program does not understand, and starting anyway with the default +// turns their mistake into a silent misconfiguration that only +// surfaces much later, somewhere else. Defaults apply to values that +// are ABSENT, and to nothing else. // // Every parse failure found in one pass is reported together, so a -// broken deployment takes one restart to diagnose rather than five. +// broken deployment takes one restart to diagnose rather than several. package config import ( "errors" "fmt" - "net/url" - "path/filepath" "strconv" "strings" - "time" - "github.com/dustin/go-humanize" "github.com/spf13/viper" - "go.uber.org/fx" // spooky action at a distance! // this populates the environment // from a ./.env file automatically // for development configuration. // .env contents should be things like - // `PORT=8080` + // `DEBUG=true` // (without the backticks, of course) _ "github.com/joho/godotenv/autoload" ) @@ -38,54 +34,12 @@ import ( // Environment variable names. Bare names, no prefix: this matches the // other services and keeps a compose file readable. const ( - EnvPort = "PORT" - EnvDataDir = "DATA_DIR" - EnvDBPath = "DB_PATH" - EnvDebug = "DEBUG" - EnvHSTS = "HSTS" - EnvBaseURL = "BASE_URL" - EnvMaxRequestBody = "MAX_REQUEST_BODY" - EnvRequestTimeout = "REQUEST_TIMEOUT" - EnvShutdownGrace = "SHUTDOWN_GRACE" - EnvSentryDSN = "SENTRY_DSN" - EnvSentryEnv = "SENTRY_ENVIRONMENT" - EnvMetricsUser = "METRICS_USER" - EnvMetricsPassword = "METRICS_PASSWORD" - EnvCSRFKey = "CSRF_KEY" + EnvDataDir = "DATA_DIR" + EnvDebug = "DEBUG" ) -// Defaults for values that are absent. A value that is present and -// unparseable never reaches these. -const ( - DefaultPort int64 = 8080 - DefaultDataDir = "./data" - DefaultBaseURL = "http://localhost:8080" - DefaultMaxRequestBody int64 = 1 << 20 // 1 MiB - DefaultRequestTimeout = 30 * time.Second - DefaultShutdownGrace = 15 * time.Second - DefaultSentryEnv = "development" -) - -// Bounds. A value inside the type but outside the range is as -// misconfigured as one that does not parse, and fails the same way. -const ( - minPort int64 = 1 - maxPort int64 = 65535 - - // minRequestBody is a floor below which no useful form submission - // fits; maxRequestBody is a ceiling above which the cap is not - // doing its job. - minRequestBody int64 = 1 << 10 // 1 KiB - maxRequestBody int64 = 1 << 26 // 64 MiB - - minTimeout = 1 * time.Second - maxTimeout = 10 * time.Minute - - // csrfKeyBytes is what gorilla/csrf requires: exactly 32 bytes, - // supplied as csrfKeyHexChars hex characters. - csrfKeyBytes = 32 - csrfKeyHexChars = csrfKeyBytes * 2 -) +// DefaultDataDir applies when DATA_DIR is absent. +const DefaultDataDir = "./data" // ErrInvalidConfig is the sentinel every configuration failure wraps, // so callers can distinguish "the operator got it wrong" from "the @@ -95,43 +49,12 @@ var ErrInvalidConfig = errors.New("invalid configuration") // Config is the parsed, validated runtime configuration. Every field // is final by the time New returns: nothing re-reads the environment // later, so there is exactly one moment at which configuration can be -// wrong, and it is before the listener opens. +// wrong, and it is before anything starts. type Config struct { - Port int - DataDir string - DBPath string - Debug bool - HSTS bool - BaseURL string - MaxRequestBody int64 - RequestTimeout time.Duration - ShutdownGrace time.Duration - - SentryDSN string - SentryEnvironment string - - // MetricsUser and MetricsPassword gate /metrics. Both set or - // neither: half-set is refused rather than resolved, because - // either resolution is dangerous. Treating a missing password as - // empty would publish the metrics endpoint to anyone who guesses - // the username; treating a missing username as "no auth" would - // publish it to everyone, in a deployment whose operator plainly - // intended it to be closed. - MetricsUser string - MetricsPassword string - - // CSRFKey is exactly 32 bytes. When CSRF_KEY is absent, a random - // key is generated at startup and a warning is logged: tokens then - // do not survive a restart, which is fine in development and not - // fine behind more than one replica. Absent is a default; present - // and malformed is a startup failure. - CSRFKey []byte - CSRFKeyEphemeral bool -} - -// Params defines dependencies for Config. -type Params struct { - fx.In + // DataDir holds the SimpleX Chat database: the bot's profile, its + // address and its contacts. Losing it loses the address. + DataDir string + Debug bool } // loader parses one environment into a Config, accumulating every @@ -166,32 +89,10 @@ func (l *loader) str(key, def string) string { return def } -func (l *loader) integer(key string, def, minVal, maxVal int64) int64 { - s, ok := l.raw(key) - if !ok { - return def - } - - n, err := strconv.ParseInt(s, 10, 64) - if err != nil { - l.fail(key, s, "not an integer") - - return def - } - - if n < minVal || n > maxVal { - l.fail(key, s, fmt.Sprintf("outside the range %d..%d", minVal, maxVal)) - - return def - } - - return n -} - // boolean accepts what strconv.ParseBool accepts (1/t/T/TRUE/true/True // and the false equivalents) and refuses everything else. "yes" is a -// parse failure on purpose: guessing at it is how a security header -// ends up off in production. +// parse failure on purpose: guessing at it is how a setting ends up the +// opposite of what the operator meant. func (l *loader) boolean(key string, def bool) bool { s, ok := l.raw(key) if !ok { @@ -208,76 +109,10 @@ func (l *loader) boolean(key string, def bool) bool { return b } -func (l *loader) duration(key string, def time.Duration) time.Duration { - s, ok := l.raw(key) - if !ok { - return def - } - - d, err := time.ParseDuration(s) - if err != nil { - l.fail(key, s, "not a duration (e.g. 30s, 2m)") - - return def - } - - if d < minTimeout || d > maxTimeout { - l.fail(key, s, fmt.Sprintf("outside the range %s..%s", minTimeout, maxTimeout)) - - return def - } - - return d -} - -// bytesize accepts both a plain integer and a human size ("1MiB", -// "512kB"), which is the form an operator actually writes. -func (l *loader) bytesize(key string, def, minVal, maxVal int64) int64 { - s, ok := l.raw(key) - if !ok { - return def - } - - n, err := humanize.ParseBytes(s) - if err != nil { - l.fail(key, s, "not a byte size (e.g. 1048576, 1MiB, 512kB)") - - return def - } - - // maxVal and minVal are compile-time constants of this package, - // both positive, so these conversions cannot overflow; n is - // range-checked before it is narrowed. - if n > uint64(maxVal) { //nolint:gosec // see above - l.fail(key, s, byteRangeMessage(minVal, maxVal)) - - return def - } - - sz := int64(n) //nolint:gosec // n was just checked against maxVal, a positive int64. - if sz < minVal { - l.fail(key, s, byteRangeMessage(minVal, maxVal)) - - return def - } - - return sz -} - -// byteRangeMessage renders the permitted size range the way an operator -// wrote the value they got wrong. -func byteRangeMessage(minVal, maxVal int64) string { - //nolint:gosec // both are positive compile-time constants of this package. - return fmt.Sprintf("outside the range %s..%s", - humanize.IBytes(uint64(minVal)), humanize.IBytes(uint64(maxVal))) -} - -// New parses and validates the environment. Returning an error here -// aborts fx startup before anything listens, which is the whole point: -// there is no partially configured running state to reason about. -// -//nolint:revive // lc parameter is required by fx even if unused. -func New(lc fx.Lifecycle, _ Params) (*Config, error) { +// New parses and validates the environment. An error here aborts +// startup before the chat client is launched, so there is no partially +// configured running state to reason about. +func New() (*Config, error) { v := viper.New() v.AutomaticEnv() @@ -289,34 +124,10 @@ func New(lc fx.Lifecycle, _ Params) (*Config, error) { func load(v *viper.Viper) (*Config, error) { l := &loader{v: v} - c := &Config{} - - c.Port = int(l.integer(EnvPort, DefaultPort, minPort, maxPort)) - c.DataDir = l.str(EnvDataDir, DefaultDataDir) - c.Debug = l.boolean(EnvDebug, false) - c.BaseURL = l.str(EnvBaseURL, DefaultBaseURL) - - // HSTS defaults to on unless debugging: pinning a developer's - // browser to HTTPS on localhost is a self-inflicted outage that - // outlives the process. - c.HSTS = l.boolean(EnvHSTS, !c.Debug) - - c.DBPath = l.str(EnvDBPath, filepath.Join(c.DataDir, "simplexcalc.db")) - c.MaxRequestBody = l.bytesize( - EnvMaxRequestBody, DefaultMaxRequestBody, minRequestBody, maxRequestBody, - ) - c.RequestTimeout = l.duration(EnvRequestTimeout, DefaultRequestTimeout) - c.ShutdownGrace = l.duration(EnvShutdownGrace, DefaultShutdownGrace) - - c.SentryDSN = l.str(EnvSentryDSN, "") - c.SentryEnvironment = l.str(EnvSentryEnv, DefaultSentryEnv) - l.checkSentryDSN(c.SentryDSN) - - c.MetricsUser = l.str(EnvMetricsUser, "") - c.MetricsPassword = l.str(EnvMetricsPassword, "") - l.checkMetricsAuth(c) - - l.loadCSRFKey(c) + c := &Config{ + DataDir: l.str(EnvDataDir, DefaultDataDir), + Debug: l.boolean(EnvDebug, false), + } if len(l.errs) > 0 { return nil, errors.Join(l.errs...) @@ -324,67 +135,3 @@ func load(v *viper.Viper) (*Config, error) { return c, nil } - -// checkSentryDSN refuses a DSN that is present and not a URL. An empty -// DSN disables Sentry and is not an error; a typo'd one that silently -// disabled it would be, since the operator would believe errors were -// being reported. -func (l *loader) checkSentryDSN(dsn string) { - if dsn == "" { - return - } - - u, err := url.Parse(dsn) - if err != nil || u.Scheme == "" || u.Host == "" { - // The DSN embeds a key; report the failure without it. - l.errs = append(l.errs, fmt.Errorf( - "%w: %s is set but is not a valid DSN URL", ErrInvalidConfig, EnvSentryDSN, - )) - } -} - -// checkMetricsAuth refuses a half-configured metrics credential. See -// the field comment on Config.MetricsUser for why neither resolution -// is acceptable. -func (l *loader) checkMetricsAuth(c *Config) { - switch { - case c.MetricsUser == "" && c.MetricsPassword == "": - return - case c.MetricsUser == "": - l.errs = append(l.errs, fmt.Errorf( - "%w: %s is set but %s is not; set both or neither", - ErrInvalidConfig, EnvMetricsPassword, EnvMetricsUser, - )) - case c.MetricsPassword == "": - l.errs = append(l.errs, fmt.Errorf( - "%w: %s is set but %s is not; set both or neither", - ErrInvalidConfig, EnvMetricsUser, EnvMetricsPassword, - )) - } -} - -// loadCSRFKey decodes CSRF_KEY, or marks the config for an ephemeral -// key. Generating the random key is deferred to the server, so that -// this function stays pure and testable. -func (l *loader) loadCSRFKey(c *Config) { - s, ok := l.raw(EnvCSRFKey) - if !ok { - c.CSRFKeyEphemeral = true - - return - } - - key, err := decodeHex(s) - if err != nil { - // The value is a secret: say what is wrong with it, never - // quote it. - l.errs = append(l.errs, fmt.Errorf( - "%w: %s is set but is not %d hex characters", - ErrInvalidConfig, EnvCSRFKey, csrfKeyHexChars, - )) - - return - } - - c.CSRFKey = key -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6c725ec..3a3a378 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,20 +2,12 @@ package config_test import ( "errors" - "strings" "testing" - "time" "github.com/spf13/viper" "sneak.berlin/go/simplexcalc/internal/config" ) -// Credentials used by the metrics-auth cases. -const ( - testUser = "scraper" - testPass = "hunter2" -) - // env builds a viper instance holding exactly the given keys, so a test // describes one environment without touching the process's. func env(kv map[string]string) *viper.Viper { @@ -33,63 +25,40 @@ func env(kv map[string]string) *viper.Viper { func TestAbsentValuesTakeDefaults(t *testing.T) { t.Parallel() - c, err := config.Load(env(nil)) - if err != nil { - t.Fatalf("empty environment must be valid, got: %v", err) - } - - if c.Port != int(config.DefaultPort) { - t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort) - } - - if c.MaxRequestBody != config.DefaultMaxRequestBody { - t.Errorf("MaxRequestBody = %d, want %d", - c.MaxRequestBody, config.DefaultMaxRequestBody) - } - - if c.RequestTimeout != config.DefaultRequestTimeout { - t.Errorf("RequestTimeout = %s, want %s", - c.RequestTimeout, config.DefaultRequestTimeout) - } - - if !c.HSTS { - t.Error("HSTS must default on when DEBUG is not set") - } - - if !c.CSRFKeyEphemeral { - t.Error("an absent CSRF_KEY must mark the config for an ephemeral key") - } -} - -// TestSetButUnparseableAborts is the central contract of this package. -// Every case is a value an operator plausibly types, and every one of -// them must fail startup rather than be replaced by the default. -func TestSetButUnparseableAborts(t *testing.T) { - t.Parallel() - - cases := map[string]map[string]string{ - "port is not a number": {config.EnvPort: "eighty"}, - "port is zero": {config.EnvPort: "0"}, - "port is above the range": {config.EnvPort: "70000"}, - "port is a float": {config.EnvPort: "8080.0"}, - "debug is yes": {config.EnvDebug: "yes"}, - "hsts is on": {config.EnvHSTS: "on"}, - "body cap is nonsense": {config.EnvMaxRequestBody: "big"}, - "body cap is too large": {config.EnvMaxRequestBody: "1TiB"}, - "body cap is too small": {config.EnvMaxRequestBody: "10"}, - "timeout has no unit": {config.EnvRequestTimeout: "30"}, - "timeout is out of range": {config.EnvRequestTimeout: "1h"}, - "grace is nonsense": {config.EnvShutdownGrace: "soon"}, - "sentry dsn is not a url": {config.EnvSentryDSN: "not a dsn"}, - "csrf key is not hex": {config.EnvCSRFKey: "not-hex-at-all"}, - "csrf key is wrong length": {config.EnvCSRFKey: "abcdef"}, - } - - for name, kv := range cases { + for name, kv := range map[string]map[string]string{ + "unset": nil, + "whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "}, + } { t.Run(name, func(t *testing.T) { t.Parallel() c, err := config.Load(env(kv)) + if err != nil { + t.Fatalf("absent values must be valid, got: %v", err) + } + + if c.DataDir != config.DefaultDataDir { + t.Errorf("DataDir = %q, want %q", c.DataDir, config.DefaultDataDir) + } + + if c.Debug { + t.Error("Debug must default off") + } + }) + } +} + +// TestSetButUnparseableAborts is the central contract of this package: +// a value an operator plausibly types, and that does not parse, fails +// startup rather than being replaced by the default. +func TestSetButUnparseableAborts(t *testing.T) { + t.Parallel() + + for _, raw := range []string{"yes", "on", "enabled", "2"} { + t.Run(raw, func(t *testing.T) { + t.Parallel() + + c, err := config.Load(env(map[string]string{config.EnvDebug: raw})) if err == nil { t.Fatalf("wanted a startup failure, got a Config: %+v", c) } @@ -105,153 +74,23 @@ func TestSetButUnparseableAborts(t *testing.T) { } } -// TestSecretsAreNotEchoed: a rejected CSRF key must not appear in the -// error, because errors are logged and a log is not a place to put a -// key. -func TestSecretsAreNotEchoed(t *testing.T) { - t.Parallel() - - const secret = "00112233445566778899aabbccdd" // valid hex, wrong length - - _, err := config.Load(env(map[string]string{config.EnvCSRFKey: secret})) - if err == nil { - t.Fatal("wanted a failure for a short CSRF key") - } - - if strings.Contains(err.Error(), secret) { - t.Errorf("the rejected key was echoed in the error: %v", err) - } -} - -// TestHalfSetMetricsAuthAborts covers the case the issue calls out -// explicitly: auth config that is half-set must fail loudly, in both -// directions. -func TestHalfSetMetricsAuthAborts(t *testing.T) { - t.Parallel() - - cases := map[string]map[string]string{ - "user without password": {config.EnvMetricsUser: testUser}, - "password without user": {config.EnvMetricsPassword: testPass}, - } - - for name, kv := range cases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - _, err := config.Load(env(kv)) - if err == nil { - t.Fatal("half-set metrics credentials must abort startup") - } - - if !errors.Is(err, config.ErrInvalidConfig) { - t.Errorf("error does not wrap ErrInvalidConfig: %v", err) - } - }) - } - - both, err := config.Load(env(map[string]string{ - config.EnvMetricsUser: testUser, config.EnvMetricsPassword: testPass, - })) - if err != nil { - t.Fatalf("both credentials set must be valid, got: %v", err) - } - - if both.MetricsUser != testUser || both.MetricsPassword != testPass { - t.Error("credentials did not survive parsing") - } - - neither, err := config.Load(env(nil)) - if err != nil { - t.Fatalf("neither credential set must be valid, got: %v", err) - } - - if neither.MetricsUser != "" || neither.MetricsPassword != "" { - t.Error("credentials appeared from nowhere") - } -} - -// TestEveryFailureIsReported: one restart should surface the whole list, -// not just the first problem. -func TestEveryFailureIsReported(t *testing.T) { - t.Parallel() - - _, err := config.Load(env(map[string]string{ - config.EnvPort: "eighty", - config.EnvDebug: "yes", - config.EnvRequestTimeout: "soon", - })) - if err == nil { - t.Fatal("wanted failures") - } - - for _, key := range []string{ - config.EnvPort, config.EnvDebug, config.EnvRequestTimeout, - } { - if !strings.Contains(err.Error(), key) { - t.Errorf("%s is broken but is not named in the error: %v", key, err) - } - } -} - // TestValidValuesAreUsed proves the parsers accept what they document. func TestValidValuesAreUsed(t *testing.T) { t.Parallel() - const key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - c, err := config.Load(env(map[string]string{ - config.EnvPort: "9000", - config.EnvDebug: "true", - config.EnvMaxRequestBody: "2MiB", - config.EnvRequestTimeout: "45s", - config.EnvShutdownGrace: "5s", - config.EnvDataDir: "/var/lib/example", - config.EnvCSRFKey: key, - })) - if err != nil { - t.Fatalf("valid environment was rejected: %v", err) - } - - if c.Port != 9000 { - t.Errorf("Port = %d, want 9000", c.Port) - } - - if c.MaxRequestBody != 2<<20 { - t.Errorf("MaxRequestBody = %d, want %d", c.MaxRequestBody, 2<<20) - } - - if c.RequestTimeout != 45*time.Second { - t.Errorf("RequestTimeout = %s, want 45s", c.RequestTimeout) - } - - if c.HSTS { - t.Error("HSTS must default off when DEBUG is true") - } - - if len(c.CSRFKey) != config.CSRFKeyBytes || c.CSRFKeyEphemeral { - t.Errorf("CSRFKey not decoded: len=%d ephemeral=%v", - len(c.CSRFKey), c.CSRFKeyEphemeral) - } - - if c.DBPath != "/var/lib/example/simplexcalc.db" { - t.Errorf("DBPath = %q, want it derived from DATA_DIR", c.DBPath) - } -} - -// TestExplicitOverridesDerivedDBPath: DB_PATH wins over the DATA_DIR -// derivation, which is the only reason it exists. -func TestExplicitOverridesDerivedDBPath(t *testing.T) { - t.Parallel() - c, err := config.Load(env(map[string]string{ config.EnvDataDir: "/var/lib/example", - config.EnvDBPath: "/srv/other.db", + config.EnvDebug: "true", })) if err != nil { t.Fatalf("valid environment was rejected: %v", err) } - if c.DBPath != "/srv/other.db" { - t.Errorf("DBPath = %q, want /srv/other.db", c.DBPath) + if c.DataDir != "/var/lib/example" { + t.Errorf("DataDir = %q, want /var/lib/example", c.DataDir) + } + + if !c.Debug { + t.Error("Debug = false, want true") } } diff --git a/internal/config/export_test.go b/internal/config/export_test.go index 004b7f8..5eeb489 100644 --- a/internal/config/export_test.go +++ b/internal/config/export_test.go @@ -11,7 +11,3 @@ package config // //nolint:gochecknoglobals // a test seam, not mutable state. var Load = load - -// CSRFKeyBytes is the required key length, so the tests can assert on -// it without restating the number. -const CSRFKeyBytes = csrfKeyBytes diff --git a/internal/config/hex.go b/internal/config/hex.go deleted file mode 100644 index 222def0..0000000 --- a/internal/config/hex.go +++ /dev/null @@ -1,29 +0,0 @@ -package config - -import ( - "encoding/hex" - "errors" - "fmt" -) - -// errKeyLength is returned for a well-formed hex string of the wrong -// length, so decodeHex has one error type for both ways of being wrong. -var errKeyLength = errors.New("wrong key length") - -// decodeHex decodes exactly csrfKeyBytes bytes of hex. It exists as its -// own function so that the length rule and the encoding rule are -// enforced in one place, and so that the caller never has to decide -// what a short-but-valid key means. -func decodeHex(s string) ([]byte, error) { - b, err := hex.DecodeString(s) - if err != nil { - return nil, fmt.Errorf("decoding hex: %w", err) - } - - if len(b) != csrfKeyBytes { - return nil, fmt.Errorf("%w: got %d bytes, want %d", - errKeyLength, len(b), csrfKeyBytes) - } - - return b, nil -} diff --git a/internal/database/database.go b/internal/database/database.go deleted file mode 100644 index d7b3ce7..0000000 --- a/internal/database/database.go +++ /dev/null @@ -1,211 +0,0 @@ -// Package database owns the sqlite connection and the schema. The -// schema is embedded in the binary, so a deployment is one file: there -// is no migrations directory to ship alongside it and no version of it -// that can be out of step with the code that expects it. -package database - -import ( - "context" - "database/sql" - "embed" - "fmt" - "log/slog" - "os" - "path/filepath" - "time" - - "go.uber.org/fx" - "sneak.berlin/go/simplexcalc/internal/config" - "sneak.berlin/go/simplexcalc/internal/logger" - - // modernc.org/sqlite is the pure-Go driver: no cgo, so the binary - // links statically and the container needs no libc. - _ "modernc.org/sqlite" -) - -// schemaFS carries the migrations into the binary. -// -//go:embed schema/*.sql -var schemaFS embed.FS - -// dirPerm is the mode for the data directory: owner-only, because it -// holds the database. -const dirPerm = 0o700 - -// pragmas are applied to every connection. WAL is what makes concurrent -// reads not block on a write; busy_timeout is what turns the remaining -// contention into a short wait rather than an immediate SQLITE_BUSY; -// foreign_keys is off by default in sqlite and has to be asked for. -const pragmas = ` -PRAGMA journal_mode = WAL; -PRAGMA busy_timeout = 5000; -PRAGMA foreign_keys = ON; -PRAGMA synchronous = NORMAL; -` - -// Params defines dependencies for Database. -type Params struct { - fx.In - - Config *config.Config - Logger *logger.Logger -} - -// Database is the handle to the application's sqlite database. -type Database struct { - db *sql.DB - log *slog.Logger -} - -// New opens the database, applies the embedded migrations, and -// registers a close hook. Migrations run during OnStart rather than -// lazily on first use: a schema that cannot be applied is a failure to -// start, and the process says so before it accepts a request. -func New(lc fx.Lifecycle, params Params) (*Database, error) { - d := &Database{log: params.Logger.Get()} - - err := os.MkdirAll(filepath.Dir(params.Config.DBPath), dirPerm) - if err != nil { - return nil, fmt.Errorf("creating data directory: %w", err) - } - - // New runs during graph construction, which has no request or - // lifecycle context of its own; the pragmas are a handful of - // in-process statements against a file that was just created. - db, err := Open(context.Background(), params.Config.DBPath) - if err != nil { - return nil, err - } - - d.db = db - - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - return d.Migrate(ctx) - }, - OnStop: func(_ context.Context) error { - d.log.Info("closing database") - - closeErr := d.db.Close() - if closeErr != nil { - return fmt.Errorf("closing database: %w", closeErr) - } - - return nil - }, - }) - - return d, nil -} - -// Open opens a sqlite database at path and applies the connection -// pragmas. Exported so tests can open a scratch database without the -// fx graph. -func Open(ctx context.Context, path string) (*sql.DB, error) { - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, fmt.Errorf("opening database %s: %w", path, err) - } - - // sqlite tolerates exactly one writer. Holding the pool to a - // single connection makes that limit explicit here rather than - // intermittent under load, and WAL keeps readers off the writer's - // back anyway. - db.SetMaxOpenConns(1) - db.SetConnMaxLifetime(time.Hour) - - _, err = db.ExecContext(ctx, pragmas) - if err != nil { - _ = db.Close() - - return nil, fmt.Errorf("applying pragmas: %w", err) - } - - return db, nil -} - -// NewForTest opens a scratch database in dir and migrates it. Test -// helper, exported so that a project seeded from this template can use -// it from any package's tests. -func NewForTest(ctx context.Context, dir string) (*Database, error) { - d := &Database{log: slog.New(slog.DiscardHandler)} - - db, err := Open(ctx, filepath.Join(dir, "test.db")) - if err != nil { - return nil, err - } - - d.db = db - - err = d.Migrate(ctx) - if err != nil { - _ = db.Close() - - return nil, err - } - - return d, nil -} - -// Migrate applies every embedded migration that has not been applied to -// this database yet. -func (d *Database) Migrate(ctx context.Context) error { - set := migrationSet{fsys: schemaFS, dir: "schema"} - - err := set.apply(ctx, d.db, d.log) - if err != nil { - return fmt.Errorf("applying migrations: %w", err) - } - - return nil -} - -// DB exposes the underlying handle for packages that need to query it. -func (d *Database) DB() *sql.DB { - return d.db -} - -// AppliedVersions returns the migration versions recorded as applied, -// ascending. The healthcheck reports the highest of them, so an -// operator can see which schema a running instance is on without -// shelling into it. -func (d *Database) AppliedVersions(ctx context.Context) ([]int, error) { - rows, err := d.db.QueryContext(ctx, - "SELECT version FROM schema_migrations ORDER BY version", - ) - if err != nil { - return nil, fmt.Errorf("reading applied migrations: %w", err) - } - defer func() { _ = rows.Close() }() - - var versions []int - - for rows.Next() { - var v int - - scanErr := rows.Scan(&v) - if scanErr != nil { - return nil, fmt.Errorf("scanning migration version: %w", scanErr) - } - - versions = append(versions, v) - } - - err = rows.Err() - if err != nil { - return nil, fmt.Errorf("iterating applied migrations: %w", err) - } - - return versions, nil -} - -// Close releases the handle. Production uses the fx OnStop hook; tests -// call this. -func (d *Database) Close() error { - err := d.db.Close() - if err != nil { - return fmt.Errorf("closing database: %w", err) - } - - return nil -} diff --git a/internal/database/database_test.go b/internal/database/database_test.go deleted file mode 100644 index f551735..0000000 --- a/internal/database/database_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package database_test - -import ( - "context" - "errors" - "path/filepath" - "testing" - - "sneak.berlin/go/simplexcalc/internal/database" -) - -// open returns a migrated scratch database in a directory the test -// framework removes afterwards. -func open(t *testing.T) *database.Database { - t.Helper() - - db, err := database.NewForTest(t.Context(), t.TempDir()) - if err != nil { - t.Fatalf("opening test database: %v", err) - } - - t.Cleanup(func() { - closeErr := db.Close() - if closeErr != nil { - t.Errorf("closing test database: %v", closeErr) - } - }) - - return db -} - -// TestMigrationsApplyFromClean is the claim the healthcheck and the -// container both rest on: an empty directory becomes a usable schema -// with no operator step in between. -func TestMigrationsApplyFromClean(t *testing.T) { - t.Parallel() - - db := open(t) - - versions, err := db.AppliedVersions(t.Context()) - if err != nil { - t.Fatalf("reading applied versions: %v", err) - } - - // 000 (the ledger) and 001 (widgets), which is every file the - // schema directory currently embeds. - if len(versions) != 2 || versions[0] != 0 || versions[1] != 1 { - t.Fatalf("applied versions = %v, want [0 1]", versions) - } -} - -// TestMigrationsAreIdempotent: a restart re-runs Migrate against a -// database that already has the schema, and must change nothing. A -// migration runner that fails here takes the service down on every -// second start. -func TestMigrationsAreIdempotent(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - ctx := t.Context() - - first, err := database.NewForTest(ctx, dir) - if err != nil { - t.Fatalf("first open: %v", err) - } - - _, err = first.CreateWidget(ctx, "survivor", 1) - if err != nil { - t.Fatalf("creating widget: %v", err) - } - - err = first.Close() - if err != nil { - t.Fatalf("closing: %v", err) - } - - second, err := database.NewForTest(ctx, dir) - if err != nil { - t.Fatalf("reopening and re-migrating: %v", err) - } - - defer func() { _ = second.Close() }() - - versions, err := second.AppliedVersions(ctx) - if err != nil { - t.Fatalf("reading applied versions: %v", err) - } - - if len(versions) != 2 { - t.Errorf("re-running migrations changed the ledger: %v", versions) - } - - // The data has to still be there: a migration runner that "fixes" - // an already-migrated database by recreating tables is worse than - // one that fails. - count, err := second.CountWidgets(ctx) - if err != nil { - t.Fatalf("counting: %v", err) - } - - if count != 1 { - t.Errorf("widget count = %d after reopen, want 1", count) - } -} - -// TestWidgetRoundTrip exercises the query layer against the real -// schema, including the timestamp format shared between Go and the SQL -// DEFAULT. -func TestWidgetRoundTrip(t *testing.T) { - t.Parallel() - - db := open(t) - ctx := t.Context() - - created, err := db.CreateWidget(ctx, "widget one", 4096) - if err != nil { - t.Fatalf("creating widget: %v", err) - } - - if created.ID == "" { - t.Error("created widget has no id") - } - - widgets, err := db.ListWidgets(ctx, 10) - if err != nil { - t.Fatalf("listing widgets: %v", err) - } - - if len(widgets) != 1 { - t.Fatalf("listed %d widgets, want 1", len(widgets)) - } - - got := widgets[0] - if got.ID != created.ID || got.Name != "widget one" || got.SizeBytes != 4096 { - t.Errorf("round trip lost data: %+v", got) - } - - if got.CreatedAt.IsZero() { - t.Error("created_at did not survive the round trip") - } -} - -// TestListWidgetsRespectsLimit: the index query is bounded, and the -// bound has to actually bind. -func TestListWidgetsRespectsLimit(t *testing.T) { - t.Parallel() - - db := open(t) - ctx := t.Context() - - for range 5 { - _, err := db.CreateWidget(ctx, "w", 1) - if err != nil { - t.Fatalf("creating widget: %v", err) - } - } - - widgets, err := db.ListWidgets(ctx, 2) - if err != nil { - t.Fatalf("listing widgets: %v", err) - } - - if len(widgets) != 2 { - t.Errorf("limit 2 returned %d rows", len(widgets)) - } -} - -// TestParseMigrationVersion covers the naming contract the schema -// directory has to keep. A file this rejects is a file that would -// otherwise be silently skipped. -func TestParseMigrationVersion(t *testing.T) { - t.Parallel() - - good := map[string]int{ - "000.sql": 0, - "001_widgets.sql": 1, - "017_thing.sql": 17, - } - - for name, want := range good { - got, err := database.ParseMigrationVersion(name) - if err != nil { - t.Errorf("%s: unexpected error %v", name, err) - - continue - } - - if got != want { - t.Errorf("%s: version = %d, want %d", name, got, want) - } - } - - for _, name := range []string{"widgets.sql", "_001.sql", "v1_widgets.sql"} { - _, err := database.ParseMigrationVersion(name) - if err == nil { - t.Errorf("%s: wanted a rejection, got none", name) - } - } -} - -// TestOpenCreatesFile: Open must produce a database at the path it was -// given, not somewhere else. -func TestOpenCreatesFile(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - - db, err := database.Open(t.Context(), filepath.Join(dir, "explicit.db")) - if err != nil { - t.Fatalf("opening: %v", err) - } - - defer func() { _ = db.Close() }() - - err = db.PingContext(t.Context()) - if err != nil && !errors.Is(err, context.Canceled) { - t.Errorf("pinging the opened database: %v", err) - } -} diff --git a/internal/database/migrate.go b/internal/database/migrate.go deleted file mode 100644 index 694baa2..0000000 --- a/internal/database/migrate.go +++ /dev/null @@ -1,199 +0,0 @@ -package database - -import ( - "context" - "database/sql" - "errors" - "fmt" - "io/fs" - "log/slog" - "path" - "sort" - "strconv" - "strings" -) - -// bootstrapVersion is 000.sql: the migration that creates the ledger -// the others are recorded in. -const bootstrapVersion = 0 - -// errBadMigrationName is returned for a schema file whose name does not -// start with a version number. It is a build-time mistake, not a -// runtime condition, and it fails startup rather than being skipped — -// a migration silently not applied is the failure mode this whole -// mechanism exists to prevent. -var errBadMigrationName = errors.New( - "migration filename does not start with a version number", -) - -// ParseMigrationVersion extracts the leading integer from a migration -// filename: "001_widgets.sql" is version 1. Exported so that a project -// seeded from this template can validate its own schema directory in a -// test. -func ParseMigrationVersion(name string) (int, error) { - base := name - if i := strings.IndexAny(base, "_."); i > 0 { - base = base[:i] - } - - version, err := strconv.Atoi(base) - if err != nil { - return 0, fmt.Errorf("%w: %q", errBadMigrationName, name) - } - - return version, nil -} - -// migrationSet is one embedded directory of numbered .sql migrations -// (000 bootstrap plus schema files). -type migrationSet struct { - fsys fs.FS - dir string -} - -// collect returns the set's migration filenames sorted -// lexicographically, which is why they are zero-padded. -func (m migrationSet) collect() ([]string, error) { - entries, err := fs.ReadDir(m.fsys, m.dir) - if err != nil { - return nil, fmt.Errorf("failed to read schema directory: %w", err) - } - - var migrations []string - - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { - migrations = append(migrations, entry.Name()) - } - } - - sort.Strings(migrations) - - return migrations, nil -} - -// bootstrap ensures the schema_migrations table exists by applying -// 000.sql if the table is missing. -func (m migrationSet) bootstrap( - ctx context.Context, db *sql.DB, log *slog.Logger, -) error { - var tableExists int - - err := db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'", - ).Scan(&tableExists) - if err != nil { - return fmt.Errorf("failed to check for migrations table: %w", err) - } - - if tableExists > 0 { - return nil - } - - content, err := fs.ReadFile(m.fsys, path.Join(m.dir, "000.sql")) - if err != nil { - return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err) - } - - if log != nil { - log.Info("applying bootstrap migration", "version", bootstrapVersion) - } - - _, err = db.ExecContext(ctx, string(content)) - if err != nil { - return fmt.Errorf("failed to apply bootstrap migration: %w", err) - } - - return nil -} - -// applied reports whether the numbered migration has been recorded. -func (m migrationSet) applied( - ctx context.Context, db *sql.DB, version int, -) (bool, error) { - var count int - - err := db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", - version, - ).Scan(&count) - if err != nil { - return false, fmt.Errorf("failed to check migration status: %w", err) - } - - return count > 0, nil -} - -// applyOne reads, executes, and records one migration file. -func (m migrationSet) applyOne( - ctx context.Context, db *sql.DB, migration string, version int, -) error { - content, err := fs.ReadFile(m.fsys, path.Join(m.dir, migration)) - if err != nil { - return fmt.Errorf("failed to read migration %s: %w", migration, err) - } - - _, execErr := db.ExecContext(ctx, string(content)) - if execErr != nil { - return fmt.Errorf("failed to apply migration %s: %w", migration, execErr) - } - - _, recErr := db.ExecContext(ctx, - "INSERT INTO schema_migrations (version) VALUES (?)", - version, - ) - if recErr != nil { - return fmt.Errorf("failed to record migration %s: %w", migration, recErr) - } - - return nil -} - -// apply runs all pending migrations of the set, in order. Idempotent: -// a second run over the same database applies nothing. -func (m migrationSet) apply(ctx context.Context, db *sql.DB, log *slog.Logger) error { - err := m.bootstrap(ctx, db, log) - if err != nil { - return err - } - - migrations, err := m.collect() - if err != nil { - return err - } - - for _, migration := range migrations { - version, parseErr := ParseMigrationVersion(migration) - if parseErr != nil { - return parseErr - } - - done, checkErr := m.applied(ctx, db, version) - if checkErr != nil { - return checkErr - } - - if done { - if log != nil { - log.Debug("migration already applied", "version", version) - } - - continue - } - - if log != nil { - log.Info("applying migration", "version", version) - } - - applyErr := m.applyOne(ctx, db, migration, version) - if applyErr != nil { - return applyErr - } - - if log != nil { - log.Info("migration applied successfully", "version", version) - } - } - - return nil -} diff --git a/internal/database/model_widget.go b/internal/database/model_widget.go deleted file mode 100644 index 47ecc4c..0000000 --- a/internal/database/model_widget.go +++ /dev/null @@ -1,106 +0,0 @@ -package database - -import ( - "context" - "fmt" - "time" - - // Go has no UUID in the standard library as of go1.25 — checked - // against this repo's toolchain, not assumed. Swap this import for - // the stdlib package the moment one lands; nothing else here - // depends on the implementation. - "github.com/google/uuid" -) - -// timeFormat matches the strftime pattern the schema uses for its -// defaults, so rows written by Go and rows written by a DEFAULT sort -// against each other correctly. -const timeFormat = "2006-01-02T15:04:05.000Z" - -// Widget is the example row type. It exists so that the migration -// runner, the query layer, the templates and the tests all exercise -// real data. Delete it when seeding a real project. -type Widget struct { - ID string - Name string - SizeBytes int64 - CreatedAt time.Time -} - -// CreateWidget inserts a widget and returns it as stored. -func (d *Database) CreateWidget( - ctx context.Context, name string, size int64, -) (*Widget, error) { - w := &Widget{ - ID: uuid.NewString(), - Name: name, - SizeBytes: size, - CreatedAt: time.Now().UTC(), - } - - _, err := d.db.ExecContext(ctx, - `INSERT INTO widgets (id, name, size_bytes, created_at) VALUES (?, ?, ?, ?)`, - w.ID, w.Name, w.SizeBytes, w.CreatedAt.Format(timeFormat), - ) - if err != nil { - return nil, fmt.Errorf("inserting widget: %w", err) - } - - return w, nil -} - -// ListWidgets returns the most recently created widgets, newest first, -// up to limit. -func (d *Database) ListWidgets(ctx context.Context, limit int) ([]Widget, error) { - rows, err := d.db.QueryContext(ctx, - `SELECT id, name, size_bytes, created_at - FROM widgets - ORDER BY created_at DESC, id DESC - LIMIT ?`, - limit, - ) - if err != nil { - return nil, fmt.Errorf("listing widgets: %w", err) - } - defer func() { _ = rows.Close() }() - - widgets := []Widget{} - - for rows.Next() { - var ( - w Widget - createdAt string - ) - - scanErr := rows.Scan(&w.ID, &w.Name, &w.SizeBytes, &createdAt) - if scanErr != nil { - return nil, fmt.Errorf("scanning widget: %w", scanErr) - } - - w.CreatedAt, scanErr = time.Parse(timeFormat, createdAt) - if scanErr != nil { - return nil, fmt.Errorf("parsing widget created_at %q: %w", createdAt, scanErr) - } - - widgets = append(widgets, w) - } - - err = rows.Err() - if err != nil { - return nil, fmt.Errorf("iterating widgets: %w", err) - } - - return widgets, nil -} - -// CountWidgets returns the number of widgets stored. -func (d *Database) CountWidgets(ctx context.Context) (int, error) { - var n int - - err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`).Scan(&n) - if err != nil { - return 0, fmt.Errorf("counting widgets: %w", err) - } - - return n, nil -} diff --git a/internal/database/schema/000.sql b/internal/database/schema/000.sql deleted file mode 100644 index b373923..0000000 --- a/internal/database/schema/000.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 000.sql: the bootstrap migration. It creates only the ledger that --- records which migrations have run; every other migration is recorded --- in it. Applied when the schema_migrations table is missing, and never --- again. --- --- Never edit an applied migration. Add a new numbered file instead: the --- ledger records versions, not contents, so an edited file is applied --- nowhere and diverges everywhere. - -CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/database/schema/001_widgets.sql b/internal/database/schema/001_widgets.sql deleted file mode 100644 index 202583f..0000000 --- a/internal/database/schema/001_widgets.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 001_widgets.sql: the example table. Delete it when seeding a real --- project and start your own schema at 001 — nothing has been deployed --- yet, so there is no ledger anywhere that would disagree. --- --- It is here so that the template's migration runner, model layer and --- tests all exercise a real table rather than an empty database. - -CREATE TABLE IF NOT EXISTS widgets ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - size_bytes INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS widgets_created_at ON widgets (created_at); diff --git a/internal/globals/globals.go b/internal/globals/globals.go deleted file mode 100644 index e00e0b2..0000000 --- a/internal/globals/globals.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package globals provides build-time variables injected via ldflags. -package globals - -import ( - "runtime" - - "go.uber.org/fx" -) - -// Build-time variables populated from main() and copied into the -// Globals object. main() sets them from its own ldflags-injected -// values; nothing else writes them. -// -//nolint:gochecknoglobals // Build-time variables set by main(). -var ( - Appname string - Version string - Buildarch string -) - -// Globals holds build-time metadata about the application. -type Globals struct { - Appname string - Version string - Buildarch string -} - -// New creates a Globals instance from the package-level build-time -// variables. -// -//nolint:revive // lc parameter is required by fx even if unused. -func New(lc fx.Lifecycle) (*Globals, error) { - arch := Buildarch - if arch == "" { - arch = runtime.GOARCH - } - - return &Globals{ - Appname: Appname, - Buildarch: arch, - Version: Version, - }, nil -} diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go deleted file mode 100644 index 1dd93c3..0000000 --- a/internal/handlers/errors.go +++ /dev/null @@ -1,13 +0,0 @@ -package handlers - -import "errors" - -var ( - // errBadWidgetName is a rejected form value, not a fault. It exists - // so that h.fail always has a non-nil error to log: a rejection - // with no error recorded is a rejection nobody can explain later. - errBadWidgetName = errors.New("widget name is empty or too long") - - // errBadWidgetSize is a size field that is not a byte count. - errBadWidgetSize = errors.New("widget size is not a byte count") -) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go deleted file mode 100644 index 9c7b6fe..0000000 --- a/internal/handlers/handlers.go +++ /dev/null @@ -1,123 +0,0 @@ -// Package handlers holds the HTTP handlers. They are methods on one -// struct whose dependencies come from fx, so a handler never reaches -// for a package-level singleton and a test can build the struct with -// exactly the collaborators it wants. -package handlers - -import ( - "log/slog" - "net/http" - - "go.uber.org/fx" - "sneak.berlin/go/simplexcalc/internal/config" - "sneak.berlin/go/simplexcalc/internal/database" - "sneak.berlin/go/simplexcalc/internal/globals" - "sneak.berlin/go/simplexcalc/internal/logger" - "sneak.berlin/go/simplexcalc/internal/middleware" - "sneak.berlin/go/simplexcalc/internal/render" - "sneak.berlin/go/simplexcalc/internal/telemetry" -) - -// Params defines dependencies for Handlers. -type Params struct { - fx.In - - Config *config.Config - Globals *globals.Globals - Logger *logger.Logger - Database *database.Database - Renderer *render.Renderer - Sentry *telemetry.Sentry -} - -// Handlers is the set of HTTP handlers. -type Handlers struct { - params Params - log *slog.Logger -} - -// New creates the handler set. -// -//nolint:revive // lc parameter is required by fx even if unused. -func New(lc fx.Lifecycle, params Params) (*Handlers, error) { - return &Handlers{params: params, log: params.Logger.Get()}, nil -} - -// NotFound answers unmatched routes with the error page rather than -// net/http's bare text, so a 404 still carries the site's own headers -// and chrome. -func (h *Handlers) NotFound() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data := render.ErrorPage{ - Page: h.page(r), - Status: http.StatusNotFound, - Message: "No such page.", - } - - err := h.params.Renderer.HTML(w, http.StatusNotFound, "error.html", data) - if err != nil { - h.log.Error("rendering 404 failed", "error", err) - http.Error(w, "not found", http.StatusNotFound) - } - } -} - -// MethodNotAllowed answers a known path with the wrong method. -func (h *Handlers) MethodNotAllowed() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data := render.ErrorPage{ - Page: h.page(r), - Status: http.StatusMethodNotAllowed, - Message: "That method is not allowed here.", - } - - err := h.params.Renderer.HTML(w, http.StatusMethodNotAllowed, "error.html", data) - if err != nil { - h.log.Error("rendering 405 failed", "error", err) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - } -} - -// page builds the common template data for r. -func (h *Handlers) page(r *http.Request) render.Page { - return h.params.Renderer.NewPage(middleware.CSRFField(r)) -} - -// fail reports a handler error and answers with the error page. -// -// The message shown to the client is chosen by the caller and is never -// the error's text: an error from the database layer carries a query, -// possibly a value out of a row, and always more about the internals -// than a stranger should be given. The error itself goes to the log and -// to Sentry, tied to the request id that is also in the response -// header, so the two can be joined afterwards. -func (h *Handlers) fail( - w http.ResponseWriter, r *http.Request, status int, message string, err error, -) { - h.log.Error("handler error", - "id", middleware.RequestIDFrom(r.Context()), - "path", r.URL.Path, - "status", status, - "error", err, - ) - - if status >= http.StatusInternalServerError { - h.params.Sentry.CaptureError(err) - } - - data := render.ErrorPage{ - Page: h.page(r), - Status: status, - Message: message, - } - - renderErr := h.params.Renderer.HTML(w, status, "error.html", data) - if renderErr != nil { - // The error page itself failed. Anything further would be - // another chance to fail, so this is the floor: a plain - // status, and the reason in the log. - h.log.Error("rendering error page failed", "error", renderErr) - http.Error(w, http.StatusText(status), status) - } -} diff --git a/internal/handlers/healthcheck.go b/internal/handlers/healthcheck.go deleted file mode 100644 index 29582bb..0000000 --- a/internal/handlers/healthcheck.go +++ /dev/null @@ -1,77 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "slices" -) - -// HealthResponse is the healthcheck body. It is a typed struct rather -// than a map so that the shape is part of the code and a change to it -// shows up in a diff: something is always parsing this. -type HealthResponse struct { - OK bool `json:"ok"` - App string `json:"app"` - Version string `json:"version"` - SchemaVersion int `json:"schema_version"` - DatabaseOK bool `json:"database_ok"` - SentryEnabled bool `json:"sentry_enabled"` - MetricsProtected bool `json:"metrics_protected"` -} - -// Healthcheck answers with the process's own view of whether it is -// working. It touches the database on purpose: a health endpoint that -// only proves the HTTP server is up will report healthy through the -// entire outage that matters. -// -// A failure answers 503, not 200-with-ok-false. Everything that reads -// this — a load balancer, a container runtime, a monitoring probe — -// looks at the status code first, and several look at nothing else. -func (h *Handlers) Healthcheck() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - resp := HealthResponse{ - App: h.params.Globals.Appname, - Version: h.params.Globals.Version, - SentryEnabled: h.params.Sentry.Enabled(), - MetricsProtected: h.params.Config.MetricsUser != "", - } - - versions, err := h.params.Database.AppliedVersions(r.Context()) - if err == nil { - resp.DatabaseOK = true - resp.OK = true - - if len(versions) > 0 { - resp.SchemaVersion = slices.Max(versions) - } - } else { - h.log.Error("healthcheck: database unreachable", "error", err) - } - - status := http.StatusOK - if !resp.OK { - status = http.StatusServiceUnavailable - } - - w.Header().Set("Content-Type", "application/json; charset=utf-8") - // A cached healthcheck is a healthcheck that reports the past. - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - - encodeErr := json.NewEncoder(w).Encode(resp) - if encodeErr != nil { - // The status and headers are already sent, so there is - // nothing to answer with; the log is the only record left. - h.log.Error("healthcheck: encoding response failed", "error", encodeErr) - } - } -} - -// Panic is a route that panics, mounted only when DEBUG is on. It is -// how the panic recoverer is exercised by hand in a running process; -// the automated proof is in the middleware tests. -func (h *Handlers) Panic() http.HandlerFunc { - return func(_ http.ResponseWriter, _ *http.Request) { - panic("deliberate panic from the debug route") - } -} diff --git a/internal/handlers/index.go b/internal/handlers/index.go deleted file mode 100644 index c17b5e5..0000000 --- a/internal/handlers/index.go +++ /dev/null @@ -1,114 +0,0 @@ -package handlers - -import ( - "net/http" - "strings" - - "github.com/dustin/go-humanize" - "sneak.berlin/go/simplexcalc/internal/render" -) - -// widgetListLimit bounds the index query. An unbounded SELECT is fine -// on the day it is written and is the outage two years later. -const widgetListLimit = 50 - -// maxWidgetNameLen matches the maxlength on the form input. The form is -// a courtesy; this is the rule. -const maxWidgetNameLen = 200 - -// Index renders the front page from the embedded template, listing the -// most recent widgets. -func (h *Handlers) Index() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - widgets, err := h.params.Database.ListWidgets(ctx, widgetListLimit) - if err != nil { - h.fail(w, r, http.StatusInternalServerError, "Could not load widgets.", err) - - return - } - - count, err := h.params.Database.CountWidgets(ctx) - if err != nil { - h.fail(w, r, http.StatusInternalServerError, "Could not count widgets.", err) - - return - } - - data := render.IndexPage{ - Page: h.page(r), - WidgetCount: count, - Widgets: widgets, - } - - err = h.params.Renderer.HTML(w, http.StatusOK, "index.html", data) - if err != nil { - h.fail(w, r, http.StatusInternalServerError, "Could not render the page.", err) - } - } -} - -// CreateWidget handles the form POST. State-changing, so it is behind -// CSRF; see internal/server/routes.go for where that is applied. -func (h *Handlers) CreateWidget() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // ParseForm reads the body, which BodyLimit has already capped: - // an oversized submission fails here rather than being buffered - // in full first. - err := r.ParseForm() - if err != nil { - h.fail(w, r, http.StatusBadRequest, "Could not read the form.", err) - - return - } - - name := strings.TrimSpace(r.PostFormValue("name")) - if name == "" || len(name) > maxWidgetNameLen { - h.fail(w, r, http.StatusBadRequest, - "A widget needs a name of 1 to 200 characters.", errBadWidgetName) - - return - } - - size, err := parseSize(r.PostFormValue("size")) - if err != nil { - h.fail(w, r, http.StatusBadRequest, - "Size must be a byte count, like 4096 or 4KiB.", err) - - return - } - - _, err = h.params.Database.CreateWidget(r.Context(), name, size) - if err != nil { - h.fail(w, r, http.StatusInternalServerError, "Could not save the widget.", err) - - return - } - - // POST/redirect/GET: a reload must not repeat the write. - http.Redirect(w, r, "/", http.StatusSeeOther) - } -} - -// parseSize accepts an empty value as zero and anything else as a -// human-readable byte size. -func parseSize(s string) (int64, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, nil - } - - n, err := humanize.ParseBytes(s) - if err != nil { - return 0, errBadWidgetSize - } - - // A size beyond this is not a widget, it is a typo with a suffix. - const maxWidgetSize = uint64(1) << 50 - if n > maxWidgetSize { - return 0, errBadWidgetSize - } - - return int64(n), nil -} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index afcd0cc..2bd927e 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -8,43 +8,15 @@ import ( "fmt" "io" "log/slog" - "os" "path/filepath" - - "go.uber.org/fx" - "sneak.berlin/go/simplexcalc/internal/globals" ) -// Params defines dependencies for Logger. -type Params struct { - fx.In - - Globals *globals.Globals - - // Output is the log destination. Optional in the fx graph: when - // absent (production) it defaults to os.Stdout; tests inject a - // buffer here to assert on the output contract. - Output io.Writer `optional:"true"` -} - -// Logger wraps slog with application-specific functionality. -type Logger struct { - log *slog.Logger - level *slog.LevelVar - globals *globals.Globals -} - -// New creates a new Logger instance. -func New(_ fx.Lifecycle, params Params) (*Logger, error) { - l := &Logger{ - level: new(slog.LevelVar), - globals: params.Globals, - } - l.level.Set(slog.LevelInfo) - - out := params.Output - if out == nil { - out = os.Stdout +// New returns a JSON logger writing to out, at debug level when debug is +// set and at info level otherwise. +func New(out io.Writer, debug bool) *slog.Logger { + level := slog.LevelInfo + if debug { + level = slog.LevelDebug } // replaceAttr simplifies the source attribute to "file.go:line". @@ -60,33 +32,9 @@ func New(_ fx.Lifecycle, params Params) (*Logger, error) { return a } - handler := slog.NewJSONHandler(out, &slog.HandlerOptions{ - Level: l.level, + return slog.New(slog.NewJSONHandler(out, &slog.HandlerOptions{ + Level: level, AddSource: true, ReplaceAttr: replaceAttr, - }) - - l.log = slog.New(handler) - - return l, nil -} - -// EnableDebugLogging sets the log level to debug. -func (l *Logger) EnableDebugLogging() { - l.level.Set(slog.LevelDebug) - l.log.Debug("debug logging enabled", "debug", true) -} - -// Get returns the underlying slog.Logger. -func (l *Logger) Get() *slog.Logger { - return l.log -} - -// Identify logs application startup information. -func (l *Logger) Identify() { - l.log.Info("starting", - "appname", l.globals.Appname, - "version", l.globals.Version, - "arch", l.globals.Buildarch, - ) + })) } diff --git a/internal/middleware/bodylimit.go b/internal/middleware/bodylimit.go deleted file mode 100644 index d6474a9..0000000 --- a/internal/middleware/bodylimit.go +++ /dev/null @@ -1,46 +0,0 @@ -package middleware - -import ( - "net/http" - "strconv" -) - -// BodyLimit caps how much of a request body a handler can read. -// -// http.MaxBytesReader is the mechanism, and the reason to use it rather -// than checking Content-Length is that Content-Length is a claim: a -// chunked request does not send one, and a lying one is trivial to -// send. MaxBytesReader counts the bytes that actually arrive and makes -// the read fail past the cap, so the ceiling holds whatever the headers -// said. -// -// It also sets the response's error status itself (413) when the limit -// is hit during a read, so a handler that ignores the read error still -// cannot serve a success off a truncated body. -// -// Content-Length is still checked first, as an early refusal: it costs -// nothing and it lets an oversized upload be rejected before it is -// transferred. -func (m *Middleware) BodyLimit() func(http.Handler) http.Handler { - limit := m.cfg.MaxRequestBody - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.ContentLength > limit { - w.Header().Set("Content-Length", strconv.Itoa(len(tooLargeBody))) - http.Error(w, tooLargeBody, http.StatusRequestEntityTooLarge) - - return - } - - r.Body = http.MaxBytesReader(w, r.Body, limit) - - next.ServeHTTP(w, r) - }) - } -} - -// tooLargeBody is the response to an oversized request. It names no -// limit: the number is an operational detail and telling a caller -// exactly where the ceiling is only helps them sit under it. -const tooLargeBody = "request body too large" diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go deleted file mode 100644 index c984ec2..0000000 --- a/internal/middleware/csrf.go +++ /dev/null @@ -1,116 +0,0 @@ -package middleware - -import ( - "crypto/rand" - "html/template" - "net/http" - "strings" - - "github.com/gorilla/csrf" -) - -// csrfCookieName is deliberately not the library default: a name that -// says which service issued it makes a cookie jar readable, and two -// services on sibling hosts do not fight over one name. -const csrfCookieName = "simplexcalc_csrf" - -// csrfMaxAge bounds how long a token stays valid, in seconds. -const csrfMaxAge = 12 * 60 * 60 - -// csrfKeyBytes is the key length gorilla/csrf requires. -const csrfKeyBytes = 32 - -// CSRF protects state-changing routes (POST, PUT, PATCH, DELETE). Safe -// methods pass through and are issued a token. -// -// The key comes from config: CSRF_KEY when set, otherwise a random key -// generated here and logged as such. An ephemeral key is correct for -// development and wrong for anything with more than one replica or more -// than one process lifetime, because a token issued by one key is -// rejected by another — the user sees a failed form submission, not a -// security event. That is why it is a warning at startup and a -// documented configuration key rather than a silent default. -func (m *Middleware) CSRF() func(http.Handler) http.Handler { - key := m.cfg.CSRFKey - - if m.cfg.CSRFKeyEphemeral { - key = make([]byte, csrfKeyBytes) - - // crypto/rand.Read cannot fail on any supported platform; it - // panics internally rather than returning an error a caller - // might ignore. A key that is not random is not a key, so - // there is nothing to fall back to here anyway. - _, _ = rand.Read(key) - - m.log.Warn("CSRF_KEY is not set; using a random key for this process", - "consequence", "tokens do not survive a restart and are not shared between replicas") - } - - protect := csrf.Protect( - key, - // Secure cookies require TLS, which is absent in local - // development; tying the flag to the same switch that governs - // HSTS keeps "is this a production deployment" a single - // decision rather than two that can disagree. - csrf.Secure(m.cfg.HSTS), - csrf.HttpOnly(true), - csrf.SameSite(csrf.SameSiteLaxMode), - csrf.Path("/"), - csrf.CookieName(csrfCookieName), - csrf.MaxAge(csrfMaxAge), - csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - m.log.Warn("csrf rejection", - "id", RequestIDFrom(r.Context()), - "path", r.URL.Path, - "reason", csrf.FailureReason(r).Error(), - ) - - http.Error(w, "invalid CSRF token", http.StatusForbidden) - })), - ) - - // markScheme must be OUTSIDE protect: it sets a context value that - // protect reads, so it has to run first. - return func(next http.Handler) http.Handler { - return markScheme(protect(next)) - } -} - -// markScheme tells gorilla/csrf whether the browser's connection was -// plaintext, because the library cannot tell and assumes it was not. -// -// Its strict Referer check is for TLS only, and it treats every request -// as TLS unless a context value says otherwise. A service behind a -// TLS-terminating reverse proxy receives plaintext HTTP with an -// https:// Referer — the library then applies the TLS rules to a -// plaintext connection and rejects every form submission, which is a -// total outage of every state-changing route rather than a subtle bug. -// Left alone, the same misreading rejects plain HTTP in development for -// the mirror-image reason. -// -// The rule: HTTPS if the connection is TLS, or if a proxy said so with -// X-Forwarded-Proto. Trusting that header is safe in this one -// direction — the only thing an attacker gains by setting it is -// STRICTER checking of their own request. The reverse (inferring -// plaintext) is what would weaken the check, and nothing a client sends -// can cause it. -// -// A deployment behind a proxy that does not set X-Forwarded-Proto gets -// the plaintext ruleset: tokens still work, and the extra Referer check -// TLS would have added is not applied. Configure the proxy. -func markScheme(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.TLS == nil && !strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { - r = csrf.PlaintextHTTPRequest(r) - } - - next.ServeHTTP(w, r) - }) -} - -// CSRFField returns the hidden input for r's token, for a template to -// place inside a form. Handlers call this rather than importing -// gorilla/csrf, so the library stays swappable behind this package. -func CSRFField(r *http.Request) template.HTML { - return csrf.TemplateField(r) -} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go deleted file mode 100644 index 1976869..0000000 --- a/internal/middleware/middleware.go +++ /dev/null @@ -1,163 +0,0 @@ -// Package middleware holds the HTTP middleware chain: request -// identity, logging, metrics, panic recovery, timeouts, body caps, -// security headers and CSRF. -// -// Order matters and is fixed in internal/server/routes.go, not here. -package middleware - -import ( - "context" - "log/slog" - "net/http" - "strconv" - "time" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "go.uber.org/fx" - "sneak.berlin/go/simplexcalc/internal/config" - "sneak.berlin/go/simplexcalc/internal/logger" - "sneak.berlin/go/simplexcalc/internal/telemetry" -) - -// contextKey is this package's private context key type, so no other -// package can collide with or read these values by accident. -type contextKey string - -// requestIDKey carries the per-request id. -const requestIDKey contextKey = "request-id" - -// RequestIDHeader is the response header the id is echoed in, so a -// user reporting a failure can quote something that finds the log line. -const RequestIDHeader = "X-Request-Id" - -// Params defines dependencies for Middleware. -type Params struct { - fx.In - - Config *config.Config - Logger *logger.Logger - Sentry *telemetry.Sentry - Metrics *telemetry.Metrics -} - -// Middleware is the set of handlers, built once and reused. -type Middleware struct { - params Params - log *slog.Logger - cfg *config.Config -} - -// New creates the middleware set. -// -//nolint:revive // lc parameter is required by fx even if unused. -func New(lc fx.Lifecycle, params Params) (*Middleware, error) { - return &Middleware{ - params: params, - log: params.Logger.Get(), - cfg: params.Config, - }, nil -} - -// RequestIDFrom returns the id assigned to r's context, or "" outside a -// request that went through RequestID. -func RequestIDFrom(ctx context.Context) string { - id, _ := ctx.Value(requestIDKey).(string) - - return id -} - -// RequestID assigns each request an id and echoes it. An id supplied by -// the client is ignored: it is attacker-controlled, it would let a -// caller collide two unrelated requests in the log, and there is no -// trusted proxy contract here that would make it meaningful. -func (m *Middleware) RequestID() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := uuid.NewString() - - w.Header().Set(RequestIDHeader, id) - - next.ServeHTTP(w, r.WithContext( - context.WithValue(r.Context(), requestIDKey, id), - )) - }) - } -} - -// RequestLogger logs one line per completed request. -func (m *Middleware) RequestLogger() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - rec := newResponseRecorder(w) - - next.ServeHTTP(rec, r) - - m.log.Info("request", - "id", RequestIDFrom(r.Context()), - "method", r.Method, - "path", r.URL.Path, - "route", routePattern(r), - "status", rec.Status(), - "bytes", rec.written, - "duration_ms", time.Since(start).Milliseconds(), - ) - }) - } -} - -// Metrics records the Prometheus series for each request. -func (m *Middleware) Metrics() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - rec := newResponseRecorder(w) - - m.params.Metrics.InFlightAdd(1) - defer m.params.Metrics.InFlightAdd(-1) - - next.ServeHTTP(rec, r) - - m.params.Metrics.Observe( - r.Method, - routePattern(r), - strconv.Itoa(rec.Status()), - time.Since(start), - ) - }) - } -} - -// Timeout bounds handler execution with the configured request timeout. -// The handler sees a context with a deadline; a handler that ignores it -// still runs to completion, so handlers must pass the context down to -// everything that can block. -func (m *Middleware) Timeout() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), m.cfg.RequestTimeout) - defer cancel() - - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -// routePattern returns the chi route pattern for r, or "unmatched" when -// no route matched (a 404). It is what the metrics and the log are -// labelled by; see the comment on the requests counter for why the path -// is not. -func routePattern(r *http.Request) string { - rctx := chi.RouteContext(r.Context()) - if rctx == nil { - return "unmatched" - } - - pattern := rctx.RoutePattern() - if pattern == "" { - return "unmatched" - } - - return pattern -} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go deleted file mode 100644 index dfd5f9e..0000000 --- a/internal/middleware/middleware_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package middleware_test - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "sneak.berlin/go/simplexcalc/internal/config" - "sneak.berlin/go/simplexcalc/internal/globals" - "sneak.berlin/go/simplexcalc/internal/logger" - "sneak.berlin/go/simplexcalc/internal/middleware" - "sneak.berlin/go/simplexcalc/internal/telemetry" -) - -// newMiddleware builds the set against a given config, with logging -// discarded and telemetry disabled. -func newMiddleware(t *testing.T, cfg *config.Config) *middleware.Middleware { - t.Helper() - - g := &globals.Globals{Appname: "simplexcalc", Version: "test"} - - log, err := logger.New(nil, logger.Params{Globals: g, Output: io.Discard}) - if err != nil { - t.Fatalf("building logger: %v", err) - } - - sentry, err := telemetry.NewSentry(nil, telemetry.SentryParams{ - Config: cfg, Globals: g, Logger: log, - }) - if err != nil { - t.Fatalf("building sentry: %v", err) - } - - metrics, err := telemetry.NewMetrics(telemetry.MetricsParams{Config: cfg}) - if err != nil { - t.Fatalf("building metrics: %v", err) - } - - mw, err := middleware.New(nil, middleware.Params{ - Config: cfg, Logger: log, Sentry: sentry, Metrics: metrics, - }) - if err != nil { - t.Fatalf("building middleware: %v", err) - } - - return mw -} - -// getReq and postReq build requests carrying the test's context, so a -// handler that respects cancellation is exercised the way the server -// exercises it. -func getReq(t *testing.T) *http.Request { - t.Helper() - - return httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) -} - -func postReq(t *testing.T, body string) *http.Request { - t.Helper() - - return httptest.NewRequestWithContext( - t.Context(), http.MethodPost, "/", strings.NewReader(body), - ) -} - -func testConfig() *config.Config { - return &config.Config{ - Port: 8080, - HSTS: true, - MaxRequestBody: 1024, - RequestTimeout: time.Second, - ShutdownGrace: time.Second, - CSRFKeyEphemeral: true, - } -} - -// TestRecovererAnswers500 is the point of the panic middleware: net/http -// on its own drops the connection, which tells the client nothing about -// whose fault it was. -func TestRecovererAnswers500(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - h := mw.Recoverer()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - panic("boom") - })) - - w := httptest.NewRecorder() - h.ServeHTTP(w, getReq(t)) - - if w.Code != http.StatusInternalServerError { - t.Fatalf("status = %d, want 500", w.Code) - } - - if w.Body.Len() == 0 { - t.Error("a 500 with no body tells the client nothing") - } - - // The panic value must not reach the client. - if strings.Contains(w.Body.String(), "boom") { - t.Error("the panic value was leaked in the response body") - } -} - -// TestRecovererPassesThroughSuccess: the recovery wrapper must be -// invisible when nothing goes wrong, including for the response body. -func TestRecovererPassesThroughSuccess(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - h := mw.Recoverer()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusTeapot) - _, _ = w.Write([]byte("fine")) - })) - - w := httptest.NewRecorder() - h.ServeHTTP(w, getReq(t)) - - if w.Code != http.StatusTeapot || w.Body.String() != "fine" { - t.Errorf("status = %d body = %q", w.Code, w.Body.String()) - } -} - -// TestSecurityHeadersOnEveryResponse, including responses the handler -// never got to write. -func TestSecurityHeadersOnEveryResponse(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - notFound := func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "not found", http.StatusNotFound) - } - - h := mw.SecurityHeaders()(http.HandlerFunc(notFound)) - - w := httptest.NewRecorder() - h.ServeHTTP(w, getReq(t)) - - want := map[string]string{ - "X-Frame-Options": "DENY", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin-when-cross-origin", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - } - - for header, value := range want { - if got := w.Header().Get(header); got != value { - t.Errorf("%s = %q, want %q", header, got, value) - } - } - - csp := w.Header().Get("Content-Security-Policy") - if !strings.Contains(csp, "default-src 'self'") { - t.Errorf("CSP = %q", csp) - } - - if strings.Contains(csp, "unsafe-inline") { - t.Error("the CSP permits inline script or style") - } -} - -// TestHSTSOffWhenDisabled: the header must be absent, not empty, so a -// developer's browser is never pinned to HTTPS on localhost. -func TestHSTSOffWhenDisabled(t *testing.T) { - t.Parallel() - - cfg := testConfig() - cfg.HSTS = false - - mw := newMiddleware(t, cfg) - - noop := func(_ http.ResponseWriter, _ *http.Request) {} - - h := mw.SecurityHeaders()(http.HandlerFunc(noop)) - - w := httptest.NewRecorder() - h.ServeHTTP(w, getReq(t)) - - if _, ok := w.Header()["Strict-Transport-Security"]; ok { - t.Error("HSTS was sent with HSTS disabled") - } -} - -// TestBodyLimitRefusesDeclaredOversize: a Content-Length over the cap is -// refused before the body transfers. -func TestBodyLimitRefusesDeclaredOversize(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - reached := false - h := mw.BodyLimit()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - reached = true - })) - - req := postReq(t, strings.Repeat("x", 2048)) - - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - - if w.Code != http.StatusRequestEntityTooLarge { - t.Errorf("status = %d, want 413", w.Code) - } - - if reached { - t.Error("the handler ran for an oversized request") - } -} - -// TestBodyLimitCapsUndeclaredBody is the case Content-Length cannot -// catch: a body that arrives without one, or with a lying one, must -// still fail at the cap rather than being read in full. -func TestBodyLimitCapsUndeclaredBody(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - var readErr error - - h := mw.BodyLimit()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - _, readErr = io.ReadAll(r.Body) - })) - - req := postReq(t, strings.Repeat("x", 4096)) - // Undeclared length: what a chunked upload looks like here. - req.ContentLength = -1 - - h.ServeHTTP(httptest.NewRecorder(), req) - - if readErr == nil { - t.Error("reading past the cap succeeded; the limit is not enforced on the read") - } -} - -// TestBodyLimitAllowsNormalRequests, so the cap is not just "refuse -// everything". -func TestBodyLimitAllowsNormalRequests(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - got := "" - h := mw.BodyLimit()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - got = string(b) - })) - - req := postReq(t, "small") - - h.ServeHTTP(httptest.NewRecorder(), req) - - if got != "small" { - t.Errorf("body = %q, want %q", got, "small") - } -} - -// TestRequestIDIsAssignedAndNotBorrowed: the id must be this process's, -// so a client cannot collide two unrelated requests in the log. -func TestRequestIDIsAssignedAndNotBorrowed(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - var inHandler string - - h := mw.RequestID()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - inHandler = middleware.RequestIDFrom(r.Context()) - })) - - req := getReq(t) - - req.Header.Set(middleware.RequestIDHeader, "client-supplied") - - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - - if inHandler == "" { - t.Fatal("no request id reached the handler") - } - - if inHandler == "client-supplied" { - t.Error("the client's request id was trusted") - } - - if w.Header().Get(middleware.RequestIDHeader) != inHandler { - t.Error("the response header does not carry the id the handler saw") - } -} - -// TestTimeoutGivesHandlerADeadline. The handler is what has to respect -// it, so what is asserted here is that the deadline is there at all. -func TestTimeoutGivesHandlerADeadline(t *testing.T) { - t.Parallel() - - mw := newMiddleware(t, testConfig()) - - var hasDeadline bool - - h := mw.Timeout()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - _, hasDeadline = r.Context().Deadline() - })) - - h.ServeHTTP(httptest.NewRecorder(), getReq(t)) - - if !hasDeadline { - t.Error("the handler's context carries no deadline") - } -} diff --git a/internal/middleware/recover.go b/internal/middleware/recover.go deleted file mode 100644 index aea0a74..0000000 --- a/internal/middleware/recover.go +++ /dev/null @@ -1,59 +0,0 @@ -package middleware - -import ( - "net/http" - "runtime/debug" -) - -// panicBody is the entire response a recovered panic produces. No -// template, no detail: the client learns that the request failed, and -// everything about why goes to the log and to Sentry, where it is not -// attacker-readable. -const panicBody = "internal server error" - -// Recoverer turns a panicking handler into a 500 rather than a dropped -// connection. -// -// net/http already recovers panics, but what it does is close the -// connection without a response, so the client sees a transport error -// and no status. Answering 500 is the difference between "the service -// is broken" and "the network is broken" for everyone downstream. -// -// A panic after the response has started cannot be turned into a 500 — -// the status is already on the wire — so in that case the connection is -// deliberately dropped by re-panicking to net/http, which is the only -// honest signal left that the body is truncated. A truncated 200 that -// looks complete is worse than a broken connection. -func (m *Middleware) Recoverer() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rec := newResponseRecorder(w) - - defer func() { - v := recover() - if v == nil { - return - } - - // http.ErrAbortHandler is net/http's documented way for - // a handler to abandon a response on purpose. It is not - // a bug, so it is not reported; it is re-raised for - // net/http to handle as it always does. - //nolint:errorlint,err113 // a sentinel value, compared as net/http documents. - if v == http.ErrAbortHandler { - panic(v) - } - - m.params.Sentry.CapturePanic(v, debug.Stack()) - - if rec.Written() { - panic(v) - } - - http.Error(rec, panicBody, http.StatusInternalServerError) - }() - - next.ServeHTTP(rec, r) - }) - } -} diff --git a/internal/middleware/responsewriter.go b/internal/middleware/responsewriter.go deleted file mode 100644 index d444cd6..0000000 --- a/internal/middleware/responsewriter.go +++ /dev/null @@ -1,63 +0,0 @@ -package middleware - -import ( - "net/http" -) - -// responseRecorder remembers the status code and byte count for the -// logger and the metrics middleware. net/http gives no way to read -// them back off an http.ResponseWriter, so the only way to know what -// was answered is to be the thing that answered it. -type responseRecorder struct { - http.ResponseWriter - - status int - written int64 - wrote bool -} - -func newResponseRecorder(w http.ResponseWriter) *responseRecorder { - // A handler that writes a body without calling WriteHeader has - // sent 200; recording that up front means Status() is right for - // the common case without waiting for a call that never comes. - return &responseRecorder{ResponseWriter: w, status: http.StatusOK} -} - -func (r *responseRecorder) WriteHeader(status int) { - if r.wrote { - return - } - - r.status = status - r.wrote = true - - r.ResponseWriter.WriteHeader(status) -} - -func (r *responseRecorder) Write(b []byte) (int, error) { - r.wrote = true - - n, err := r.ResponseWriter.Write(b) - r.written += int64(n) - - //nolint:wrapcheck // pass-through writer: wrapping would obscure the underlying error. - return n, err -} - -// Status returns the status code that was sent. -func (r *responseRecorder) Status() int { - return r.status -} - -// Written reports whether anything has been sent yet. The panic -// recoverer needs this: it can only substitute a 500 for a response -// that has not started. -func (r *responseRecorder) Written() bool { - return r.wrote -} - -// Unwrap lets http.ResponseController reach the underlying writer, so -// wrapping does not cost the handler flushing or deadline control. -func (r *responseRecorder) Unwrap() http.ResponseWriter { - return r.ResponseWriter -} diff --git a/internal/middleware/security.go b/internal/middleware/security.go deleted file mode 100644 index efcc0e1..0000000 --- a/internal/middleware/security.go +++ /dev/null @@ -1,67 +0,0 @@ -package middleware - -import "net/http" - -// Security response headers. -const ( - // hstsValue is served even where TLS terminates at a reverse - // proxy, so the browser enforces HTTPS end to end. Off when - // config.HSTS is false (development), because pinning a - // developer's browser to HTTPS on localhost is a self-inflicted - // outage that outlives the process. - hstsValue = "max-age=31536000; includeSubDomains" - - // cspValue is the baseline. Every template ships with external CSS - // and no inline script, style or event handler, so nothing needs - // 'unsafe-inline' and nothing should be given it: the moment a - // project seeded from this template adds 'unsafe-inline', the - // policy stops being a defence against injected script and becomes - // decoration. - cspValue = "default-src 'self'; " + - "base-uri 'self'; " + - "form-action 'self'; " + - "frame-ancestors 'none'; " + - "object-src 'none'" - - // permissionsPolicyValue denies the browser features this - // application does not use. - permissionsPolicyValue = "accelerometer=(), autoplay=(), camera=(), " + - "display-capture=(), encrypted-media=(), geolocation=(), " + - "gyroscope=(), magnetometer=(), microphone=(), midi=(), " + - "payment=(), picture-in-picture=(), " + - "publickey-credentials-get=(), screen-wake-lock=(), usb=(), " + - "xr-spatial-tracking=()" - - referrerPolicyValue = "strict-origin-when-cross-origin" - frameOptionsValue = "DENY" - contentTypeOptsVal = "nosniff" -) - -// SecurityHeaders sets the response security headers before the handler -// runs, so they are on every response the router produces — 404s, -// handler error bodies, static assets, and the bare 500 the panic -// recoverer writes. -// -// X-Frame-Options duplicates the CSP frame-ancestors directive on -// purpose, for browsers that do not implement the latter. -func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler { - hsts := m.cfg.HSTS - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h := w.Header() - - if hsts { - h.Set("Strict-Transport-Security", hstsValue) - } - - h.Set("Content-Security-Policy", cspValue) - h.Set("X-Frame-Options", frameOptionsValue) - h.Set("X-Content-Type-Options", contentTypeOptsVal) - h.Set("Referrer-Policy", referrerPolicyValue) - h.Set("Permissions-Policy", permissionsPolicyValue) - - next.ServeHTTP(w, r) - }) - } -} diff --git a/internal/render/errors.go b/internal/render/errors.go deleted file mode 100644 index 9df3016..0000000 --- a/internal/render/errors.go +++ /dev/null @@ -1,13 +0,0 @@ -package render - -import "errors" - -var ( - // errNoTemplates means the embed matched nothing — a build that - // produced a binary with no pages in it. - errNoTemplates = errors.New("no page templates were embedded") - - // errUnknownTemplate means a handler asked for a page that is not - // in the embedded set: a typo, caught by that handler's test. - errUnknownTemplate = errors.New("unknown template") -) diff --git a/internal/render/render.go b/internal/render/render.go deleted file mode 100644 index 4847a3e..0000000 --- a/internal/render/render.go +++ /dev/null @@ -1,240 +0,0 @@ -// Package render parses the embedded templates once, at startup, and -// executes them into a buffer before writing anything to the client. -// -// Two decisions worth keeping when this is seeded into a real project: -// -// - Every template is parsed in New. A template that does not compile -// is a process that does not start, rather than a 500 the first time -// someone visits the page it broke. -// - Execution goes to a buffer first. A template that fails halfway -// through would otherwise have already written a 200 and half a -// page, and the error could no longer be reported as one. -package render - -import ( - "bytes" - "fmt" - "html/template" - "io" - "io/fs" - "net/http" - "path" - "sort" - "strings" - "time" - - "github.com/dustin/go-humanize" - "go.uber.org/fx" - "sneak.berlin/go/simplexcalc/internal/database" - "sneak.berlin/go/simplexcalc/internal/globals" - "sneak.berlin/go/simplexcalc/templates" -) - -// baseTemplate is the outer document every page is rendered through. -const baseTemplate = "base" - -// Params defines dependencies for Renderer. -type Params struct { - fx.In - - Globals *globals.Globals -} - -// Renderer holds one compiled template set per page. -type Renderer struct { - pages map[string]*template.Template - globals *globals.Globals - started time.Time -} - -// Page is the data every template can rely on, embedded by the -// page-specific types below so that promoted fields keep the templates -// free of a data-envelope prefix. -type Page struct { - AppName string - Version string - Buildarch string - Uptime string - - // CSRFField is the hidden input gorilla/csrf validates. It is - // template.HTML because it is markup this process generated, not - // input; nothing user-supplied is ever assigned to it. - CSRFField template.HTML -} - -// IndexPage is the data for index.html. -type IndexPage struct { - Page - - WidgetCount int - Widgets []database.Widget -} - -// ErrorPage is the data for error.html. -type ErrorPage struct { - Page - - Status int - Message string -} - -// funcs are the template helpers. Deliberately few: logic belongs in -// the handler, where it can be tested without parsing HTML. -func funcs() template.FuncMap { - return template.FuncMap{ - // bytes renders a byte count the way an operator reads one. - "bytes": func(n int64) string { - if n < 0 { - return "-" - } - - return humanize.IBytes(uint64(n)) - }, - // since renders a timestamp as "3 minutes ago". - "since": humanize.Time, - } -} - -// New compiles every page template against the base document and the -// partials. -// -//nolint:revive // lc parameter is required by fx even if unused. -func New(lc fx.Lifecycle, params Params) (*Renderer, error) { - r := &Renderer{ - pages: map[string]*template.Template{}, - globals: params.Globals, - started: time.Now(), - } - - shared, pages, err := split(templates.FS) - if err != nil { - return nil, err - } - - for _, page := range pages { - // Each page gets its own set: pages define blocks of the same - // names ("title", "content"), so parsing them all into one - // template would leave whichever was parsed last defining both - // for everybody. - set := template.New(baseTemplate).Funcs(funcs()) - - set, err = set.ParseFS(templates.FS, append(append([]string{}, shared...), page)...) - if err != nil { - return nil, fmt.Errorf("parsing template %s: %w", page, err) - } - - r.pages[path.Base(page)] = set - } - - if len(r.pages) == 0 { - return nil, errNoTemplates - } - - return r, nil -} - -// split separates the embedded set into the files every page needs -// (the base document and the partials) and the page templates -// themselves. -func split(fsys fs.FS) ([]string, []string, error) { - partials, err := fs.Glob(fsys, "partials/*.html") - if err != nil { - return nil, nil, fmt.Errorf("globbing partials: %w", err) - } - - top, err := fs.Glob(fsys, "*.html") - if err != nil { - return nil, nil, fmt.Errorf("globbing templates: %w", err) - } - - var pages []string - - shared := append([]string{}, partials...) - - for _, f := range top { - if strings.TrimSuffix(path.Base(f), ".html") == baseTemplate { - shared = append(shared, f) - - continue - } - - pages = append(pages, f) - } - - sort.Strings(shared) - sort.Strings(pages) - - return shared, pages, nil -} - -// NewPage returns the common data, filled in from build-time globals -// and the request's CSRF field. -func (r *Renderer) NewPage(csrfField template.HTML) Page { - return Page{ - AppName: r.globals.Appname, - Version: r.globals.Version, - Buildarch: r.globals.Buildarch, - Uptime: time.Since(r.started).Round(time.Second).String(), - CSRFField: csrfField, - } -} - -// Execute renders a page into w. It buffers first: see the package -// comment. -func (r *Renderer) Execute(w io.Writer, name string, data any) error { - set, ok := r.pages[name] - if !ok { - return fmt.Errorf("%w: %s", errUnknownTemplate, name) - } - - var buf bytes.Buffer - - err := set.ExecuteTemplate(&buf, baseTemplate, data) - if err != nil { - return fmt.Errorf("executing template %s: %w", name, err) - } - - _, err = buf.WriteTo(w) - if err != nil { - return fmt.Errorf("writing rendered template %s: %w", name, err) - } - - return nil -} - -// HTML renders a page to an http.ResponseWriter with the given status. -// A render failure after the buffer succeeded cannot happen, so the -// status written here is always the status the client sees. -func (r *Renderer) HTML( - w http.ResponseWriter, status int, name string, data any, -) error { - var buf bytes.Buffer - - err := r.Execute(&buf, name, data) - if err != nil { - return err - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(status) - - _, err = buf.WriteTo(w) - if err != nil { - return fmt.Errorf("writing response: %w", err) - } - - return nil -} - -// Names returns the compiled page names, sorted. Tests use it to assert -// that every embedded page really compiled. -func (r *Renderer) Names() []string { - names := make([]string, 0, len(r.pages)) - for name := range r.pages { - names = append(names, name) - } - - sort.Strings(names) - - return names -} diff --git a/internal/render/render_test.go b/internal/render/render_test.go deleted file mode 100644 index ffc0998..0000000 --- a/internal/render/render_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package render_test - -import ( - "bytes" - "html/template" - "net/http/httptest" - "strings" - "testing" - "time" - - "sneak.berlin/go/simplexcalc/internal/database" - "sneak.berlin/go/simplexcalc/internal/globals" - "sneak.berlin/go/simplexcalc/internal/render" - "sneak.berlin/go/simplexcalc/templates" -) - -func newRenderer(t *testing.T) *render.Renderer { - t.Helper() - - r, err := render.New(nil, render.Params{ - Globals: &globals.Globals{ - Appname: "simplexcalc", Version: "test", Buildarch: "amd64", - }, - }) - if err != nil { - t.Fatalf("compiling templates: %v", err) - } - - return r -} - -// TestEveryEmbeddedPageCompiles is the reason New parses everything at -// startup: a template that does not compile must be a process that does -// not start, and this is what proves the set is complete rather than -// just non-empty. -func TestEveryEmbeddedPageCompiles(t *testing.T) { - t.Parallel() - - r := newRenderer(t) - - embedded, err := templates.FS.ReadDir(".") - if err != nil { - t.Fatalf("reading embedded templates: %v", err) - } - - want := 0 - - for _, e := range embedded { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".html") && e.Name() != "base.html" { - want++ - } - } - - if want == 0 { - t.Fatal("no page templates were embedded, so this test proves nothing") - } - - if got := len(r.Names()); got != want { - t.Errorf("compiled %d pages (%v), embedded %d", got, r.Names(), want) - } -} - -// TestIndexRendersEmbeddedContent renders the page the service serves -// at /, with real data, and checks that the base document, both -// partials and the page body all made it into one response. -func TestIndexRendersEmbeddedContent(t *testing.T) { - t.Parallel() - - r := newRenderer(t) - - data := render.IndexPage{ - Page: r.NewPage(template.HTML(``)), - WidgetCount: 1, - Widgets: []database.Widget{ - { - ID: "abc", Name: "a widget", SizeBytes: 4096, - CreatedAt: time.Now().Add(-time.Hour), - }, - }, - } - - var buf bytes.Buffer - - err := r.Execute(&buf, "index.html", data) - if err != nil { - t.Fatalf("rendering index: %v", err) - } - - out := buf.String() - - for _, want := range []string{ - "", // base document - `href="/static/css/style.css"`, // base document links the embedded asset - "