From f8ce8cef83da294a93dfc65cb4964da93e21d5a2 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sat, 26 Sep 2026 21:38:57 +0000 Subject: [PATCH] Seed from go-template-repo, renamed to simplexcalc The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5 --- .dockerignore | 26 ++ .editorconfig | 15 + .gitea/workflows/check.yml | 18 + .gitignore | 23 + .golangci.yml | 41 ++ .prettierignore | 9 + .prettierrc | 9 + AGENTS.md | 178 +++++++ Dockerfile | 114 +++++ Dockerfile.lint | 35 ++ Makefile | 54 +++ cmd/simplexcalc/main.go | 37 ++ cmd/simplexcalc/root.go | 48 ++ cmd/simplexcalc/serve.go | 80 ++++ docs/REPO_POLICIES.md | 446 ++++++++++++++++++ docs/TODO.md | 53 +++ go.mod | 51 +++ go.sum | 152 ++++++ internal/app/app.go | 72 +++ internal/config/config.go | 390 ++++++++++++++++ internal/config/config_test.go | 257 +++++++++++ internal/config/export_test.go | 17 + 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 | 92 ++++ 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/telemetry/metrics.go | 162 +++++++ internal/telemetry/metrics_test.go | 152 ++++++ internal/telemetry/sentry.go | 112 +++++ internal/telemetry/testing_test.go | 43 ++ script/assert-context-complete | 166 +++++++ script/assert-step-ran | 125 +++++ script/bootstrap | 84 ++++ script/check | 15 + script/cibuild | 89 ++++ script/docker | 15 + script/fmt | 26 ++ script/fmt-check | 25 + script/install-precommit | 16 + script/lint | 71 +++ script/precommit | 21 + script/prettier | 52 +++ script/projectname | 15 + script/repo-source-manifest | 60 +++ script/setup | 14 + script/test | 21 + 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 + 79 files changed, 7131 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitea/workflows/check.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 Dockerfile.lint create mode 100644 Makefile create mode 100644 cmd/simplexcalc/main.go create mode 100644 cmd/simplexcalc/root.go create mode 100644 cmd/simplexcalc/serve.go create mode 100644 docs/REPO_POLICIES.md create mode 100644 docs/TODO.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/export_test.go create mode 100644 internal/config/hex.go create mode 100644 internal/database/database.go create mode 100644 internal/database/database_test.go create mode 100644 internal/database/migrate.go create mode 100644 internal/database/model_widget.go create mode 100644 internal/database/schema/000.sql create mode 100644 internal/database/schema/001_widgets.sql create mode 100644 internal/globals/globals.go create mode 100644 internal/handlers/errors.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/handlers/healthcheck.go create mode 100644 internal/handlers/index.go create mode 100644 internal/logger/logger.go create mode 100644 internal/middleware/bodylimit.go create mode 100644 internal/middleware/csrf.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/middleware/middleware_test.go create mode 100644 internal/middleware/recover.go create mode 100644 internal/middleware/responsewriter.go create mode 100644 internal/middleware/security.go create mode 100644 internal/render/errors.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/server/http.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/staticfs.go create mode 100644 internal/telemetry/metrics.go create mode 100644 internal/telemetry/metrics_test.go create mode 100644 internal/telemetry/sentry.go create mode 100644 internal/telemetry/testing_test.go create mode 100755 script/assert-context-complete create mode 100755 script/assert-step-ran create mode 100755 script/bootstrap create mode 100755 script/check create mode 100755 script/cibuild create mode 100755 script/docker create mode 100755 script/fmt create mode 100755 script/fmt-check create mode 100755 script/install-precommit create mode 100755 script/lint create mode 100755 script/precommit create mode 100755 script/prettier create mode 100755 script/projectname create mode 100755 script/repo-source-manifest create mode 100755 script/setup create mode 100755 script/test create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 static/static.go create mode 100644 templates/base.html create mode 100644 templates/error.html create mode 100644 templates/index.html create mode 100644 templates/partials/footer.html create mode 100644 templates/partials/navbar.html create mode 100644 templates/templates.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..27510ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Never exclude Go sources, go.mod, go.sum or .golangci.yml: the lint +# and test stages only ever examine what reaches the build context, and +# an exclusion here makes them pass over a tree that is missing a +# package. script/assert-context-complete exists to catch exactly that, +# and will fail the build rather than let it happen silently. +.git/ +.gitea/ +bin/ +data/ +AGENTS.md +README.md +docs/ +LICENSE +.editorconfig +.prettierrc +.prettierignore +.env +.env.* +*.db +*.sqlite +*.sqlite3 +.DS_Store +.idea/ +.vscode/ +tmp/ +temp/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f52b09b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.go] +indent_style = tab + +[Makefile] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..c8a085f --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,18 @@ +name: check + +on: + push: + branches: + - "**" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 + # The image build is the gate: it runs gofmt, config verify, + # lint and test, and script/cibuild refuses a build in which + # any of them was cached or skipped. + - name: cibuild + run: script/cibuild diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..697e196 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +/bin/ +/data/ +.env +.env.* +*.key +*.pem +*.db +*.sqlite +*.sqlite3 +.DS_Store +*~ +*.swp + +# Editor and assistant tooling directories. These are per-developer state and +# never belong in the repository; documentation for contributors, human or +# otherwise, lives in AGENTS.md. +.idea/ +.vscode/ +.claude/ +.cursor/ +.aider* +.continue/ +.windsurf/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..be5a2d9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,41 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: all + disable: + # Genuinely incompatible with project patterns + - exhaustruct # Requires all struct fields + - depguard # Dependency allow/block lists + - godot # Requires comments to end with periods + - wsl # Deprecated, replaced by wsl_v5 + - wrapcheck # Too verbose for internal packages + - varnamelen # Short names like db, id are idiomatic Go + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 + tagliatelle: + # snake_case JSON, deliberately. Every operational consumer + # of these payloads — jq in a shell, a Prometheus exporter + # config, a monitoring probe — is easier to write against + # snake_case, and the exposition format next door already + # uses it. This is a repo-wide decision, not a per-struct + # exception, so it lives here rather than in nolint + # directives spread across the handlers. + case: + rules: + json: snake + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..686797b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +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/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0dfc332 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": false, + "tabWidth": 4, + "proseWrap": "always", + "printWidth": 72, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1216381 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,178 @@ +--- +title: Agent Guidance +last_modified: 2026-08-22 +--- + +This file is the single source of guidance for any automated agent +working in this repository. There is no other agent-facing file, and +none is to be created: no vendor-named directory and no vendor-named +markdown file is ever committed. + +Keeping such directories out of commits is what `.gitignore` is for, and +listing them there is correct — an ignore entry is plumbing, not +documentation. What must not appear is vendor-named _content_: committed +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. + +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. + +## Iron rules + +These are not preferences. A change that breaks one of them is wrong +regardless of what else it achieves. + +1. **Never mention any assistant, model, vendor, or the involvement of + automated assistance** — not in code, comments, documentation, commit + messages, PR bodies, or trailers. No `Co-Authored-By`, no session + links, no attribution of any kind. + +2. **Linting runs in Docker, never on the host.** `script/lint` builds + `Dockerfile.lint` against a digest-pinned image. Do not install + `golangci-lint` locally and do not run it directly. + +3. **Never run `go build`, `go test`, `go vet`, `gofmt` or + `golangci-lint` directly.** Use the `make` targets or the `script/` + entrypoints. They carry the flags and policies (`-count=1`, `-race`, + timeouts, docker-only linting) that a raw invocation silently + bypasses. This applies to mid-task checks, not just the final one. + +4. **Configuration that is set but unparseable aborts startup.** A + default applies only to a value that is ABSENT. Silently substituting + a default for a value the operator got wrong turns their mistake into + a misconfiguration that surfaces much later, somewhere else. See + `internal/config`. Reject any change that weakens this. + +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. + +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. + +8. **Clean up every container and image you create. Never run + `docker builder prune`, `docker image prune`, or any other prune.** + Shared infrastructure purges on its own schedule; a prune destroys + other people's work in progress. + +9. **Write verbatim identifiers in backticks in markdown** — branch + names, filenames, commands, environment variables. This is what + distinguishes a branch literally named `next` from the word "none" + meaning no such branch exists. + +## Entrypoints + +Everything is a `script/` entrypoint with a thin `make` shim. The +scripts are the real interface; `make` exists because fingers know it. +See the Entrypoints section of `README.md` for what each one does. + +The two that matter most: + +- `make check` — `test` + `lint` + `fmt-check`. Never modifies files. + This is the gate for a commit. +- `script/cibuild` — the container build with the lint and test stages + cache-busted, plus assertions that they really ran and really saw the + whole repository. This is the gate for a push, and the Gitea workflow + runs it. + +A green `docker build` on its own proves nothing: an unchanged tree +serves every layer from cache and exits 0 having executed nothing, and a +`.dockerignore` entry can hide a package from a linter that then +truthfully reports `0 issues.` over what is left. +`script/assert-step-ran` and `script/assert-context-complete` exist to +close both holes, and their comments explain what they still cannot see. +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. +- `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 + treated as lost. +- Never force-push, and never rewrite published history. + +## 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 +``` + +## 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. +- **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. +- **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 new file mode 100644 index 0000000..82376a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,114 @@ +# Lint stage — fast feedback on formatting and lint issues. Tools are +# invoked directly (not via make/script): the docker build is its own +# single path. +# This stage must stay the one that runs golangci-lint, and its name must +# match $lint_stage in script/cibuild, which cache-busts it by name. +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint + +WORKDIR /src + +# Copy go mod files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Inventory of the sources that actually arrived here. script/cibuild +# reads these lines out of the build log and compares them against the +# git index, so a .dockerignore entry or a narrowed COPY that hides a +# package fails the run instead of yielding a clean report over a tree +# the linter never saw. Keep it immediately after `COPY . .`, and keep +# `echo context-manifest-begin` as its first command: +# script/assert-context-complete matches the step by that prefix. +RUN echo context-manifest-begin; \ + { find . -type f -name '*.go'; \ + for f in go.mod go.sum .golangci.yml .golangci.yaml; do \ + if [ -f "$f" ]; then echo "./$f"; fi; \ + done; } \ + | sed 's|^\./||' | LC_ALL=C sort | sed 's|^|context-file: |'; \ + echo context-manifest-end + +# Formatting check, config check, linter +RUN test -z "$(gofmt -s -l .)" || { echo "gofmt needed on:"; gofmt -s -l .; exit 1; } +RUN golangci-lint config verify --config .golangci.yml +RUN golangci-lint run --config .golangci.yml ./... + +# Build stage. Must stay the one that runs go test, and its name must +# match $test_stage in script/cibuild, which cache-busts it by name. +# golang:1.25.7-bookworm (Debian-based: the race detector used by the +# test run requires glibc), 2026-08-07 +FROM golang:1.25.7-bookworm@sha256:564e366a28ad1d70f460a2b97d1d299a562f08707eb0ecb24b659e5bd6c108e1 AS builder + +# Depend on lint stage passing (forces BuildKit ordering) +COPY --from=lint /src/go.sum /dev/null + +WORKDIR /build + +# Copy go mod files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Same inventory as the lint stage, and separately checked: this stage +# has its own COPY, so an intact context over there is no evidence about +# the tree `go test ./...` is about to walk here. A package that did not +# arrive is a package the tests never run, and the run still ends `ok`. +RUN echo context-manifest-begin; \ + { find . -type f -name '*.go'; \ + for f in go.mod go.sum .golangci.yml .golangci.yaml; do \ + if [ -f "$f" ]; then echo "./$f"; fi; \ + done; } \ + | sed 's|^\./||' | LC_ALL=C sort | sed 's|^|context-file: |'; \ + echo context-manifest-end + +# Run tests: quiet first, verbose rerun on failure (and still fail). +# -count=1 disables the test result cache, matching script/test. +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. +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o bin/simplexcalc ./cmd/simplexcalc + +# Runtime stage, and the last one: script/cibuild passes no --target, so +# 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 + +RUN apk --no-cache add ca-certificates + +# Create non-root user +RUN addgroup -g 1000 -S simplexcalc && \ + adduser -u 1000 -S simplexcalc -G 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 + +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"] diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..9753f9b --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,35 @@ +# Lint image, built by script/lint: golangci-lint runs as a build step, so +# a successful build is a clean lint. Works with a remote docker daemon, +# where bind mounts are impossible. + +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +# This stage must stay the last one and the one that runs golangci-lint, +# and its name must match $stage in script/lint. +FROM deps AS lint + +COPY . . + +# Inventory of the sources that actually arrived here. script/lint reads +# these lines out of the build log and compares them against the git +# index, so a .dockerignore entry or a narrowed COPY that hides a +# package fails the run instead of yielding a clean report over a tree +# the linter never saw. Keep it immediately after `COPY . .`, and keep +# `echo context-manifest-begin` as its first command: +# script/assert-context-complete matches the step by that prefix. +RUN echo context-manifest-begin; \ + { find . -type f -name '*.go'; \ + for f in go.mod go.sum .golangci.yml .golangci.yaml; do \ + if [ -f "$f" ]; then echo "./$f"; fi; \ + done; } \ + | sed 's|^\./||' | LC_ALL=C sort | sed 's|^|context-file: |'; \ + echo context-manifest-end + +RUN golangci-lint config verify --config .golangci.yml +RUN golangci-lint run --config .golangci.yml ./... diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e6f8d80 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker cibuild clean hooks + +# Every target is a thin shim over script/. The scripts are the real +# entrypoints: they carry the project-specific knowledge (flags, +# timeouts, docker-only linting) that a raw `go test` or `golangci-lint` +# invocation silently bypasses. Never call the underlying tools +# directly. +.DEFAULT_GOAL := check + +bootstrap: + @script/bootstrap + +setup: + @script/setup + +test: + @script/test + +lint: + @script/lint + +fmt: + @script/fmt + +fmt-check: + @script/fmt-check + +check: + @script/check + +build: + go build -o bin/simplexcalc ./cmd/simplexcalc + +run: build + ./bin/simplexcalc serve + +dev: + go run ./cmd/simplexcalc serve + +deps: + go mod download + go mod tidy + +docker: + @script/docker + +cibuild: + @script/cibuild + +clean: + rm -rf bin/ + +hooks: + @script/install-precommit diff --git a/cmd/simplexcalc/main.go b/cmd/simplexcalc/main.go new file mode 100644 index 0000000..ee0c325 --- /dev/null +++ b/cmd/simplexcalc/main.go @@ -0,0 +1,37 @@ +// 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. +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. +const appname = "simplexcalc" + +// version is injected at build time with -ldflags "-X main.version=...". +// The Dockerfile passes VERSION; a `go build` without it says "dev", +// which is exactly what a locally built binary is. It is a package +// variable because the linker has no other way to reach it. +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 + // status, which is what a supervisor and a shell script read. + fmt.Fprintln(os.Stderr, "exiting: "+err.Error()) + os.Exit(1) + } +} diff --git a/cmd/simplexcalc/root.go b/cmd/simplexcalc/root.go new file mode 100644 index 0000000..8fa2690 --- /dev/null +++ b/cmd/simplexcalc/root.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +// 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. +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" + + "optional .env file in the working directory. See README.md\n" + + "for the full list of variables.", + SilenceUsage: true, + // Without this, cobra prints the error itself and main prints + // it again. + SilenceErrors: true, + } + + root.AddCommand(serveCmd(), versionCmd()) + + return root +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "print the version and exit", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s/%s\n", + appname, version, runtime.GOOS, runtime.GOARCH) + if err != nil { + return fmt.Errorf("writing version: %w", err) + } + + return nil + }, + } +} diff --git a/cmd/simplexcalc/serve.go b/cmd/simplexcalc/serve.go new file mode 100644 index 0000000..a20a7df --- /dev/null +++ b/cmd/simplexcalc/serve.go @@ -0,0 +1,80 @@ +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/REPO_POLICIES.md b/docs/REPO_POLICIES.md new file mode 100644 index 0000000..4e35b65 --- /dev/null +++ b/docs/REPO_POLICIES.md @@ -0,0 +1,446 @@ +--- +title: Repository Policies +last_modified: 2026-08-07 +--- + +This document covers repository structure, tooling, and workflow +standards. Code style conventions are in separate documents: + +- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) + (general, bash, Docker) +- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) +- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) +- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) +- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) + +--- + +- Cross-project documentation (such as this file) must include + `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept + in sync with the authoritative source as policies evolve. + +- **ALL external references must be pinned by cryptographic hash.** This + includes Docker base images, Go modules, npm packages, GitHub Actions, + and anything else fetched from a remote source. Version tags (`@v4`, + `@latest`, `:3.21`, etc.) are server-mutable and therefore remote code + execution vulnerabilities. The ONLY acceptable way to reference an + external dependency is by its content hash (Docker `@sha256:...`, Go + module hash in `go.sum`, npm integrity hash in lockfile, GitHub + Actions `@`). No exceptions. This also means never + `curl | bash` to install tools like pyenv, nvm, rustup, etc. Instead, + download a specific release archive from GitHub, verify its hash + (hardcoded in the Dockerfile or script), and only then install. + Unverified install scripts are arbitrary remote code execution. This + is the single most important rule in this document. Double-check every + external reference in every file before committing. There are zero + exceptions to this rule. + +- Every repo with software must have a root `Makefile` with these + targets: `make bootstrap`, `make setup`, `make test`, `make lint`, + `make fmt` (writes), `make fmt-check` (read-only), `make check` (runs + `test`, `lint`, `fmt-check`), `make docker`, and `make hooks` + (installs pre-commit hook). A model Makefile is at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + pattern: the implementation of each Makefile target lives in an + executable script in `script/` (`script/bootstrap`, `script/setup`, + `script/test`, `script/lint`, `script/fmt`, `script/fmt-check`, + `script/check`, `script/docker`), and the Makefile targets are thin + shims that call them. The scripts must be POSIX sh (`#!/bin/sh`, + `set -eu`, no bashisms) so they run in minimal containers (e.g. alpine + images have no bash); locate the repo root with + `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. + From the standard's canonical set we use `bootstrap`, `setup` (make + the repo ready for development after a fresh clone: runs `bootstrap`, + then `install-precommit`, plus any repo-specific initialization), + `test`, and `cibuild`. `script/bootstrap` installs all dependencies + idempotently and assumes nothing is present: base tools come from nix, + apt, brew, or apk (detected in that order; apt runs noninteractive). + For node it uses the installed node if present; otherwise it installs + a PINNED node version via nvm, first installing nvm itself if missing + — from a hash-verified GitHub release archive (never `curl | sh`), + with bash installed as an explicit prerequisite since nvm requires + bash. yarn is then pinned via + `corepack prepare yarn@ --activate`. Never install "latest" + or "lts"; always exact versions. `script/cibuild` runs the CI build: + it changes to the repo root and runs `docker build .`; the Gitea + workflow calls it. Four further scripts are our own extensions to the + standard: `script/check` runs `script/test`, `script/lint`, and + `script/fmt-check`; `script/precommit` is what the git pre-commit hook + runs, and it calls `script/check`; `script/install-precommit` installs + the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the + project's name. Scripts that need the name call `script/projectname` — + e.g. `script/docker` assembles its image tag from it — so those + scripts stay byte-identical across all repos. Repo-type-specific + pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong + in `script/precommit`, not in the hook itself. Model scripts are at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The + README must document the provided scripts in an **Entrypoints** + section (see the README requirements below). + +- Always use Makefile targets (`make fmt`, `make test`, `make lint`, + etc.) instead of invoking the underlying tools directly. The Makefile + is the single source of truth for how these operations are run. + +- The Makefile is authoritative documentation for how the repo is used. + Beyond the required targets above, it should have targets for every + common operation: running a local development server (`make run`, + `make dev`), re-initializing or migrating the database + (`make db-reset`, `make migrate`), building artifacts (`make build`), + generating code, seeding data, or anything else a developer would do + regularly. If someone checks out the repo and types `make`, they + should see every meaningful operation available. A new contributor + should be able to understand the entire development workflow by + reading the Makefile. + +- Every repo should have a `Dockerfile`. All Dockerfiles must run + `make check` as a build step so the build fails if the branch is not + green. For non-server repos, the Dockerfile should bring up a + development environment and run `make check`. For server repos, + `make check` should run as an early build stage before the final image + is assembled. Dockerfiles install development prerequisites by running + `script/bootstrap` rather than duplicating installs inline; COPY + `script/` and the dependency manifests (`package.json` + `yarn.lock`, + `go.mod` + `go.sum`, etc.) before running it so the bootstrap layer + stays cached until dependencies change. + +- **Dockerfiles must use a separate lint stage for fail-fast feedback.** + Go repos use a multistage build where linting runs in an independent + stage based on the `golangci/golangci-lint` image (pinned by hash). + This stage runs `make fmt-check` and `make lint` before the full build + begins. The build stage then declares an explicit dependency on the + lint stage via `COPY --from=lint /src/go.sum /dev/null`, which forces + BuildKit to complete linting before proceeding to compilation and + tests. This ensures lint failures surface in seconds rather than + minutes, without blocking on dependency download or compilation in the + build stage. + + The standard pattern for a Go repo Dockerfile is: + + ```dockerfile + # Lint stage — fast feedback on formatting and lint issues + # golangci/golangci-lint:v2.x.x, YYYY-MM-DD + FROM golangci/golangci-lint@sha256:... AS lint + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN make fmt-check + RUN make lint + + # Build stage + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + WORKDIR /src + + # Force BuildKit to run the lint stage before proceeding + COPY --from=lint /src/go.sum /dev/null + + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN make test + + ARG VERSION=dev + RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint stage uses the `golangci/golangci-lint` image directly + (it includes both Go and the linter), so there is no need to + install the linter separately. + - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that + creates a stage dependency. BuildKit runs stages in parallel by + default; without this line, the build stage would not wait for + lint to finish and a lint failure might not fail the overall + build. + - If the project uses `//go:embed` directives that reference build + artifacts (e.g. a web frontend compiled in a separate stage), the + lint stage must create placeholder files so the embed directives + resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + The lint stage should not depend on the actual build output — it + exists to fail fast. + - If the project requires CGO or system libraries for linting (e.g. + `vips-dev`), install them in the lint stage with `apk add`. + - The build stage runs `make test` after compilation setup. Tests + run in the build stage, not the lint stage, because they may + require compiled artifacts or heavier dependencies. + +- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) + that runs `script/cibuild` (which runs `docker build .`) on push. + Since the Dockerfile already runs `make check`, a successful build + implies all checks pass. + +- Use platform-standard formatters: `black` for Python, `prettier` for + JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default + configuration with two exceptions: four-space indents (except Go), and + `proseWrap: always` for Markdown (hard-wrap at 80 columns). + Documentation and writing repos (Markdown, HTML, CSS) should also have + `.prettierrc` and `.prettierignore`. + +- Pre-commit hook: runs `script/precommit`, which calls `script/check`. + If local testing is not possible in the repo, `script/precommit` may + skip `script/test` and run only `script/lint` and `script/fmt-check`. + The hook is installed by `script/install-precommit`; the Makefile must + provide a `make hooks` target that shims to it. + +- All repos with software must have tests that run via the + platform-standard test framework (`go test`, `pytest`, + `jest`/`vitest`, etc.). If no meaningful tests exist yet, add the most + minimal test possible — e.g. importing the module under test to verify + it compiles/parses. There is no excuse for `make test` to be a no-op. + +- `make test` must complete in under 60 seconds. That is the hard cap, + and a suite that exceeds it fails. Under 20 seconds is the target. A + suite between 20 and 60 seconds is still green, but the overage must + be filed as an improvement bug against that repo. Add a 90-second + timeout to the test invocation in the Makefile + (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely + slow one. + +- **`make test` should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun + with `-v` to show full output. This keeps CI logs and `docker build` + output clean on success (just package/suite summaries) while providing + full diagnostic detail on failure (every test case, every assertion). + The general shell pattern: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + ``` + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + The `exit 1` ensures the target always fails after a rerun — the + first run already proved the tests are broken, so the build must not + pass even if a flaky test happens to succeed on the second attempt. + The rerun exists solely for diagnostic output. + +- Docker builds must complete in under 5 minutes. + +- `make check` must not modify any files in the repo. Tests may use + temporary directories. + +- `main` must always pass `make check`, no exceptions. + +- Never commit secrets. `.env` files, credentials, API keys, and private + keys must be in `.gitignore`. No exceptions. + +- `.gitignore` should be comprehensive from the start: OS files + (`.DS_Store`), editor files (`.swp`, `*~`), language build artifacts, + and `node_modules/`. Fetch the standard `.gitignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. + +- **No build artifacts in version control.** Code-derived data (compiled + bundles, minified output, generated assets) must never be committed to + the repository if it can be avoided. The build process (e.g. + Dockerfile, Makefile) should generate these at build time. Notable + exception: Go protobuf generated files (`.pb.go`) ARE committed + because repos need to work with `go get`, which downloads code but + does not execute code generation. + +- Never use `git add -A` or `git add .`. Always stage files explicitly + by name. + +- Never force-push to `main`. + +- Make all changes on a feature branch. You can do whatever you want on + a feature branch. + +- `.golangci.yml` is standardized and must _NEVER_ be modified by an + agent, only manually by the user. Fetch from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The + canonical golangci-lint version is v2.12.2 (released 2026-05-06), + installed commit-pinned via + `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. + +- When pinning images or packages by hash, add a comment above the + reference with the version and date (YYYY-MM-DD). + +- Use `yarn`, not `npm`. + +- Write all dates as YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables. + +- Dockerized web services listen on port 8080 by default, overridable + with `PORT`. + +- **HTTP/web services must be hardened for production internet exposure + before tagging 1.0.** This means full compliance with security best + practices including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least + one year and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default + policy (`default-src 'self'` as a baseline, tightened + per-resource as needed). Never use `unsafe-inline` or + `unsafe-eval` unless unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is + required). Prefer the `frame-ancestors` CSP directive as the + primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or + stricter). + - `Permissions-Policy` restricting access to browser features + the application does not use (camera, microphone, geolocation, + etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never + accept unbounded input. + - Maximum response body size where applicable (e.g. paginated + APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to + defend against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API + keys are high-entropy and not susceptible to brute force, so + they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API + key) are exempt because the browser does not attach these + automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never + plain-text, MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and + `SameSite=Lax` (or `Strict`) attributes. + - **Reverse proxy awareness:** + - True client IP detection when behind a reverse proxy + (`X-Forwarded-For`, `X-Real-IP`). The application must accept + forwarded headers only from a configured set of trusted proxy + addresses — never trust `X-Forwarded-For` unconditionally. + - **CORS:** + - Authenticated endpoints must restrict + `Access-Control-Allow-Origin` to an explicit allowlist of + known origins. Wildcard (`*`) is acceptable only for public, + unauthenticated read-only APIs. + - **Error handling:** + - Internal errors must never leak stack traces, SQL queries, + file paths, or other implementation details to the client. + Return generic error messages in production; detailed errors + only when `DEBUG` is enabled. + - **TLS:** + - Services never terminate TLS directly. They are always + deployed behind a TLS-terminating reverse proxy. The service + itself listens on plain HTTP. However, HSTS headers and + `Secure` cookie flags must still be set by the application so + that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard + security hardening measure exists for HTTP services and is not + listed here, it is still expected. When in doubt, harden. + +- `README.md` is the primary documentation. Required sections: + - **Description**: First line must include the project name, + purpose, category (web server, SPA, CLI tool, etc.), license, and + author. Example: "µPaaS is an MIT-licensed Go web application by + @sneak that receives git-frontend webhooks and deploys + applications via Docker in realtime." + - **Getting Started**: Copy-pasteable install/usage code block. + - **Entrypoints**: Opens by stating that the repo adheres to the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. + - **Rationale**: Why does this exist? + - **Design**: How is the program structured? + - **TODO**: Update meticulously, even between commits. When + planning, put the todo list in the README so a new agent can pick + up where the last one left off. + - **License**: MIT, GPL, or WTFPL. Ask the user for new projects. + Include a `LICENSE` file in the repo root and a License section in + the README. + - **Author**: [@sneak](https://sneak.berlin). + +- First commit of a new repo should contain only `README.md`. + +- Go module root: `sneak.berlin/go/`. Always run `go mod tidy` + before committing. + +- Use SemVer. + +- Database migrations live in `internal/db/migrations/` and must be + embedded in the binary. + - `000_migration.sql` — contains ONLY the creation of the migrations + tracking table itself. Nothing else. + - `001_schema.sql` — the full application schema. + - **Pre-1.0.0:** never add additional migration files (002, 003, + etc.). There is no installed base to migrate. Edit + `001_schema.sql` directly. + - **Post-1.0.0:** add new numbered migration files for each schema + change. Never edit existing migrations after release. + +- All repos should have an `.editorconfig` enforcing the project's + indentation settings. + +- Avoid putting files in the repo root unless necessary. Root should + contain only project-level config files (`Makefile`, `Dockerfile`, + `.gitignore`, `.editorconfig`, and language-specific config) plus + exactly three documentation files: `README.md`, `AGENTS.md` and + `LICENSE`. Every other markdown file — `REPO_POLICIES.md`, `TODO.md`, + design notes, runbooks — lives under `docs/`. Everything else goes in + a subdirectory. Canonical subdirectory names: + - `bin/` — executable scripts and tools + - `cmd/` — Go command entrypoints + - `configs/` — configuration templates and examples + - `deploy/` — deployment manifests (k8s, compose, terraform) + - `docs/` — all markdown except the three root files above + - `internal/` — Go internal packages + - `internal/db/migrations/` — database migrations + - `pkg/` — Go library packages + - `share/` — systemd units, data files + - `static/` — static assets (images, fonts, etc.) + - `web/` — web frontend source + +- When setting up a new repo, files from the `prompts` repo may be used + as templates. Fetch them from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/`. + +- New repos must contain at minimum: + - `README.md`, `AGENTS.md`, `.git`, `.gitignore`, `.editorconfig` + - `LICENSE`, `docs/REPO_POLICIES.md` (copy from the `prompts` repo) + - `Makefile` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, + `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, + `precommit`, `install-precommit`) + - `Dockerfile`, `.dockerignore` + - `.gitea/workflows/check.yml` + - Go: `go.mod`, `go.sum`, `.golangci.yml` + - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` + - Python: `pyproject.toml` diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..70b329b --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,53 @@ +# Workflow + +- branch (from `main`) +- do the work in Next Step +- move Next Step to the top of Completed Steps +- move the top item of Future Steps into Next Step +- commit (`docs/TODO.md` changes in the same commit as the work) +- merge to `main` if the branch is not protected, otherwise open a PR +- push + +# 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. + +# 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. + +# 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 + +# 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e193ae0 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +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/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/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/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/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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e239f35 --- /dev/null +++ b/go.sum @@ -0,0 +1,152 @@ +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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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 new file mode 100644 index 0000000..4eda63d --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,72 @@ +// 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/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8c30e92 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,390 @@ +// Package config loads runtime configuration from the environment (and +// an optional ./.env file) via viper. +// +// 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. +// +// Every parse failure found in one pass is reported together, so a +// broken deployment takes one restart to diagnose rather than five. +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` + // (without the backticks, of course) + _ "github.com/joho/godotenv/autoload" +) + +// 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" +) + +// 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 +) + +// ErrInvalidConfig is the sentinel every configuration failure wraps, +// so callers can distinguish "the operator got it wrong" from "the +// machine is broken" without string matching. +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. +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 +} + +// loader parses one environment into a Config, accumulating every +// failure instead of stopping at the first, so one restart surfaces the +// whole list. +type loader struct { + v *viper.Viper + errs []error +} + +func (l *loader) fail(key, raw, why string) { + l.errs = append(l.errs, fmt.Errorf( + "%w: %s=%q is %s", ErrInvalidConfig, key, raw, why, + )) +} + +// raw returns the trimmed value of key, and whether it was set to +// anything. Whitespace-only counts as absent: it is what an empty +// compose-file entry produces, and no key here has a meaningful blank +// value. +func (l *loader) raw(key string) (string, bool) { + s := strings.TrimSpace(l.v.GetString(key)) + + return s, s != "" +} + +func (l *loader) str(key, def string) string { + if s, ok := l.raw(key); ok { + return s + } + + 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. +func (l *loader) boolean(key string, def bool) bool { + s, ok := l.raw(key) + if !ok { + return def + } + + b, err := strconv.ParseBool(s) + if err != nil { + l.fail(key, s, "not a boolean (use true or false)") + + return def + } + + 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) { + v := viper.New() + v.AutomaticEnv() + + return load(v) +} + +// load is New's body against an explicit viper instance, so tests can +// drive it with a known environment instead of mutating the process's. +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) + + if len(l.errs) > 0 { + return nil, errors.Join(l.errs...) + } + + 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 new file mode 100644 index 0000000..6c725ec --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,257 @@ +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 { + v := viper.New() + for k, val := range kv { + v.Set(k, val) + } + + return v +} + +// TestAbsentValuesTakeDefaults pins the other half of the iron rule: a +// value that is not set does get the default. Without this, a bug that +// rejected everything would pass every test below. +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 { + t.Run(name, func(t *testing.T) { + t.Parallel() + + c, err := config.Load(env(kv)) + if err == nil { + t.Fatalf("wanted a startup failure, got a Config: %+v", c) + } + + if !errors.Is(err, config.ErrInvalidConfig) { + t.Errorf("error does not wrap ErrInvalidConfig: %v", err) + } + + if c != nil { + t.Error("a failed load must return no Config at all") + } + }) + } +} + +// 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", + })) + 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) + } +} diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 0000000..004b7f8 --- /dev/null +++ b/internal/config/export_test.go @@ -0,0 +1,17 @@ +package config + +// Load is load, exported for the external test package. +// +// The tests live in config_test rather than config so that they drive +// this package the way the rest of the program does — through its +// exported surface — and cannot quietly depend on an internal detail. +// The one thing they need that is not exported is the ability to +// supply an environment instead of reading the process's, which is a +// testing seam and not API. +// +//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 new file mode 100644 index 0000000..222def0 --- /dev/null +++ b/internal/config/hex.go @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..d7b3ce7 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,211 @@ +// 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 new file mode 100644 index 0000000..f551735 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,219 @@ +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 new file mode 100644 index 0000000..694baa2 --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,199 @@ +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 new file mode 100644 index 0000000..47ecc4c --- /dev/null +++ b/internal/database/model_widget.go @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..b373923 --- /dev/null +++ b/internal/database/schema/000.sql @@ -0,0 +1,15 @@ +-- 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 new file mode 100644 index 0000000..202583f --- /dev/null +++ b/internal/database/schema/001_widgets.sql @@ -0,0 +1,15 @@ +-- 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 new file mode 100644 index 0000000..e00e0b2 --- /dev/null +++ b/internal/globals/globals.go @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000..1dd93c3 --- /dev/null +++ b/internal/handlers/errors.go @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..9c7b6fe --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,123 @@ +// 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 new file mode 100644 index 0000000..29582bb --- /dev/null +++ b/internal/handlers/healthcheck.go @@ -0,0 +1,77 @@ +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 new file mode 100644 index 0000000..c17b5e5 --- /dev/null +++ b/internal/handlers/index.go @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..afcd0cc --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,92 @@ +// Package logger provides structured logging using stdlib log/slog. +// +// JSON output always, one format for every environment, TTY or not. A +// log line is a record to be queried, not prose to be read. +package logger + +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 + } + + // replaceAttr simplifies the source attribute to "file.go:line". + replaceAttr := func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + if src, ok := a.Value.Any().(*slog.Source); ok { + a.Value = slog.StringValue( + fmt.Sprintf("%s:%d", filepath.Base(src.File), src.Line), + ) + } + } + + return a + } + + handler := slog.NewJSONHandler(out, &slog.HandlerOptions{ + Level: l.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 new file mode 100644 index 0000000..d6474a9 --- /dev/null +++ b/internal/middleware/bodylimit.go @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..c984ec2 --- /dev/null +++ b/internal/middleware/csrf.go @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..1976869 --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,163 @@ +// 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 new file mode 100644 index 0000000..dfd5f9e --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,314 @@ +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 new file mode 100644 index 0000000..aea0a74 --- /dev/null +++ b/internal/middleware/recover.go @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..d444cd6 --- /dev/null +++ b/internal/middleware/responsewriter.go @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..efcc0e1 --- /dev/null +++ b/internal/middleware/security.go @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..9df3016 --- /dev/null +++ b/internal/render/errors.go @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..4847a3e --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,240 @@ +// 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 new file mode 100644 index 0000000..ffc0998 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,197 @@ +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 + "