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 + "