1 Commits
Author SHA1 Message Date
clawbot 8f65fd7b05 Move pixad's startup from cmd/pixad into internal/app (closes #206)
check / check (push) Failing after 2s
cmd/pixad/main.go built the command line and its --config flag, set
PIXA_CONFIG_PATH from that flag, ignored SIGPIPE and started the fx
app. REPO_POLICIES.md now requires cmd/ to hold only one call into
internal/ or pkg/, so that code moves unchanged to Run in the new
internal/app package, and main calls app.Run(Version). Version stays
in main, so the -X main.Version build flags in the Dockerfile and the
Makefile do not change.

Model: opus-5-5
2026-10-04 22:27:09 +00:00
37 changed files with 761 additions and 1884 deletions
-5
View File
@@ -66,8 +66,3 @@
.gitignore .gitignore
/bin /bin
/data /data
# Local config files, kept out of git because they can hold the signing key.
**/[cC][oO][nN][fF][iI][gG].[yY][mM][lL]
**/[cC][oO][nN][fF][iI][gG].[yY][aA][mM][lL]
**/[cC][oO][nN][fF][iI][gG].[dD][eE][vV].[yY][mM][lL]
-5
View File
@@ -6,10 +6,5 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# The default clone is shallow and has no tags, so the
# version the build takes from `git describe` would be a
# bare commit; this fetches the whole history with its tags.
with:
fetch-depth: 0
- run: script/cibuild - run: script/cibuild
- run: script/docker-smoke - run: script/docker-smoke
-7
View File
@@ -11,12 +11,6 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Environment / secrets # Environment / secrets
.env .env
.env.* .env.*
@@ -37,6 +31,5 @@ node_modules/
*.sqlite3 *.sqlite3
# Local dev configs # Local dev configs
config.yml
config.yaml config.yaml
config.dev.yml config.dev.yml
-7
View File
@@ -1,7 +0,0 @@
node_modules/
yarn.lock
# A byte-for-byte copy of the one in sneak/prompts.
REPO_POLICIES.md
vendor/
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+55 -50
View File
@@ -4,68 +4,73 @@ Last Updated 2026-01-08
These rules MUST be followed at all times, it is very important. These rules MUST be followed at all times, it is very important.
- Never use `git add -A` - add specific changes to a deliberate commit. A commit * Never use `git add -A` - add specific changes to a deliberate commit. A
should contain one change. After each change, make a commit with a good commit should contain one change. After each change, make a commit with a
one-line summary. good one-line summary.
- NEVER modify the linter config without asking first. * NEVER modify the linter config without asking first.
- NEVER modify tests to exclude special cases or otherwise get them to pass * NEVER modify tests to exclude special cases or otherwise get them to pass
without asking first. In almost all cases, the code should be changed, NOT the without asking first. In almost all cases, the code should be changed,
tests. If you think the test needs to be changed, make your case for that and NOT the tests. If you think the test needs to be changed, make your case
ask for permission to proceed, then stop. You need explicit user approval to for that and ask for permission to proceed, then stop. You need explicit
modify existing tests. (You do not need user approval for writing NEW tests.) user approval to modify existing tests. (You do not need user approval
for writing NEW tests.)
- When linting, assume the linter config is CORRECT, and that each item output * When linting, assume the linter config is CORRECT, and that each item
by the linter is something that legitimately needs fixing in the code. output by the linter is something that legitimately needs fixing in the
code.
- When running tests, use `make test`. * When running tests, use `make test`.
- Before commits, run `make check`. This runs `make lint` and `make test` and * Before commits, run `make check`. This runs `make lint` and `make test`
`make check-fmt`. Any issues discovered MUST be resolved before committing and `make check-fmt`. Any issues discovered MUST be resolved before
unless explicitly told otherwise. committing unless explicitly told otherwise.
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate * When fixing a bug, write a failing test for the bug FIRST. Add
logging to the test to ensure it is written correctly. Commit that. Then go appropriate logging to the test to ensure it is written correctly. Commit
about fixing the bug until the test passes (without modifying the test that. Then go about fixing the bug until the test passes (without
further). Then commit that. modifying the test further). Then commit that.
- When adding a new feature, do the same - implement a test first (TDD). It * When adding a new feature, do the same - implement a test first (TDD). It
doesn't have to be super complex. Commit the test, then commit the feature. doesn't have to be super complex. Commit the test, then commit the
feature.
- When adding a new feature, use a feature branch. When the feature is * When adding a new feature, use a feature branch. When the feature is
completely finished and the code is up to standards (passes `make check`) then completely finished and the code is up to standards (passes `make check`)
and only then can the feature branch be merged into `main` and the branch then and only then can the feature branch be merged into `main` and the
deleted. branch deleted.
- Write godoc documentation comments for all exported types and functions as you * Write godoc documentation comments for all exported types and functions as
go along. you go along.
- ALWAYS be consistent in naming. If you name something one thing in one place, * ALWAYS be consistent in naming. If you name something one thing in one
name it the EXACT SAME THING in another place. place, name it the EXACT SAME THING in another place.
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is * Be descriptive and specific in naming. `wl` is bad;
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good. `SourceHostWhitelist` is good. `ConnsPerHost` is bad;
`MaxConnectionsPerHost` is good.
- This is not prototype or teaching code - this is designed for production. Any * This is not prototype or teaching code - this is designed for production.
security issues (such as denial of service) or other web vulnerabilities are Any security issues (such as denial of service) or other web
P1 bugs and must be added to TODO.md at the top. vulnerabilities are P1 bugs and must be added to TODO.md at the top.
- As this is production code, no stubbing of implementations unless specifically * As this is production code, no stubbing of implementations unless
instructed. We need working implementations. specifically instructed. We need working implementations.
- NEVER silently fall back to a different setting when a user's parameter * NEVER silently fall back to a different setting when a user's parameter
explicitly specifies a value. If a user requests format=webp and WebP encoding explicitly specifies a value. If a user requests format=webp and WebP
is not supported, return an error - do NOT silently output PNG instead. If a encoding is not supported, return an error - do NOT silently output PNG
user specifies fit=invalid and that fit mode doesn't exist, return an error - instead. If a user specifies fit=invalid and that fit mode doesn't exist,
do NOT silently default to "cover". Silent fallbacks violate the principle of return an error - do NOT silently default to "cover". Silent fallbacks
least surprise and mask bugs. The only acceptable defaults are for OMITTED violate the principle of least surprise and mask bugs. The only acceptable
parameters, never for INVALID explicit values. defaults are for OMITTED parameters, never for INVALID explicit values.
- Avoid vendoring deps unless specifically instructed to. NEVER commit the * Avoid vendoring deps unless specifically instructed to. NEVER commit
vendor directory, NEVER commit compiled binaries. If these directories or the vendor directory, NEVER commit compiled binaries. If these
files exist, add them to .gitignore (and commit the .gitignore) if they are directories or files exist, add them to .gitignore (and commit the
not already in there. Keep the entire git repository (with history) small - .gitignore) if they are not already in there. Keep the entire git
under 20MiB, unless you specifically must commit larger files (e.g. test repository (with history) small - under 20MiB, unless you specifically
fixture example media files). Only OUR source code and immediately supporting must commit larger files (e.g. test fixture example media files). Only
files (such as test examples) goes into the repo/history. OUR source code and immediately supporting files (such as test examples)
goes into the repo/history.
+43 -51
View File
@@ -1,62 +1,55 @@
# Lint phase. script/lint builds it alone. The linter is run directly: # Lint stage
# `make lint` and script/lint are themselves a docker build. # Same image as Dockerfile.lint: change both pins together.
# golangci/golangci-lint:v2.12.2, 2026-10-04 # golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
# The linter compiles every package, and govips needs the libvips
# headers for that. REPO_POLICIES.md has the lint phase install them
# itself; this image is Debian, so with apt-get rather than apk.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. script/test builds it alone.
# golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
WORKDIR /src WORKDIR /src
# script/bootstrap --cgo installs the build dependencies (a C compiler # script/bootstrap installs the build dependencies and downloads the Go
# and the libvips and libheif headers) and downloads the Go modules. # modules. Only script/, go.mod and go.sum are copied first, so this
# layer is reused until one of them changes.
COPY script/ ./script/ COPY script/ ./script/
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN script/bootstrap --cgo RUN script/bootstrap
COPY . .
# Without -v first; on a failure, again with -v for the details, and
# the step fails even if the second run passes.
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; }
# Build stage. Nothing is wanted from the two phases above: these copies
# make BuildKit build them first, so this stage runs only when lint and
# test passed.
# golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src
# Build dependencies and Go modules, as in the test phase
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap --cgo
# Copy source code # Copy source code
COPY . . COPY . .
# Tells script/lint it is inside a container, so it runs the linter.
ENV container=docker
# Run formatting check and linter. script/cibuild and script/docker pass
# a new CHECK_EPOCH on every run, and each check step names it in its
# command, so a new value reruns the step instead of reusing a cached
# success that checked nothing. A plain `docker build .` leaves it empty
# and reuses the check steps only for an identical build context.
ARG CHECK_EPOCH
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN echo "check epoch: ${CHECK_EPOCH}" && make lint
# Build stage
# golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
WORKDIR /src
# Build dependencies and Go modules, as in the lint stage
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap
# Copy source code
COPY . .
# Run tests; a new CHECK_EPOCH reruns them, as in the lint stage.
ARG CHECK_EPOCH
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# VERSION is declared here, not earlier: a new value reruns only the # VERSION is declared here, not earlier: a new value reruns only the
# build, not script/bootstrap. Given none, the version is # build, not script/bootstrap or the tests. Given none, the version is
# `git describe --tags --always` of the .git in the build context (git # `git describe --tags --always` of the .git in the build context (git
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH # comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
# after one, the short commit when no tag is reachable. A context that # after one, the short commit when no tag is reachable. A context that
@@ -75,8 +68,7 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
-ldflags "-s -w -X main.Version=${version}" \ -ldflags "-s -w -X main.Version=${version}" \
-o /pixad ./cmd/pixad -o /pixad ./cmd/pixad
# Runtime stage, and the last one: a plain `docker build .` builds this # Runtime stage
# stage and what it depends on, and nothing else.
# alpine:3.21, 2026-02-25 # alpine:3.21, 2026-02-25
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
+34
View File
@@ -0,0 +1,34 @@
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
# which is never installed on the host. Pinned to the same image as the
# Dockerfile lint stage: change both pins together, or the two run
# different linter versions.
#
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
WORKDIR /src
# pixa is CGO/libvips: the type-aware linters compile every package, so
# this image needs the same C libraries the build does. script/bootstrap
# installs them and downloads the Go modules. Only script/, go.mod and
# go.sum are copied first; they settle this layer's result, so it may
# safely be reused between runs.
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap
COPY . .
# Tells script/lint it is inside a container, so it runs the linter.
ENV container=docker
# script/lint passes a different CACHEBUST on every run, and BuildKit
# keys every RUN after this ARG on its value, so the lint step always
# runs instead of returning a cached success that linted nothing.
#
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
# tmpfs that is discarded after the step. Written into the layer, they
# would pile up as build cache on every run, since no later run, with
# its new CACHEBUST, can reuse that layer.
ARG CACHEBUST
RUN --mount=type=tmpfs,target=/root/.cache script/lint
+10 -15
View File
@@ -1,10 +1,10 @@
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest .PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
# Use nix-shell to provide CGO dependencies unless they are already available # Use nix-shell to provide CGO dependencies unless they are already available
# (e.g. inside an existing nix-shell). # (e.g. inside a Docker build or an existing nix-shell).
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null) HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
ifdef HAS_PKGCONFIG ifdef HAS_PKGCONFIG
NIX_RUN_PREFIX = NIX_RUN_PREFIX =
@@ -32,11 +32,11 @@ fmt-check:
fmt: fmt:
@script/fmt @script/fmt
# Run linter (the lint phase of the Dockerfile) # Run linter
lint: lint:
@script/lint @script/lint
# Run tests (the test phase of the Dockerfile) # Run tests (30-second timeout)
test: test:
@script/test @script/test
@@ -59,25 +59,20 @@ docker:
docker-smoke: docker-smoke:
@script/docker-smoke @script/docker-smoke
# Measure throughput, latency and peak memory with the default duration and # Build Docker image tagged pixad:$(VERSION) and pixad:latest
# number of clients (needs Docker and Go; a benchmark, not part of check)
loadtest:
@script/loadtest
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
docker-versioned: docker-versioned:
@script/docker docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
docker tag pixa pixa:$(VERSION)
# Run tests in Docker, as `make test` does # Run tests in Docker (needed for CGO/libvips)
docker-test: docker-test:
@script/test docker build --target builder --build-arg VERSION=$(VERSION) -t pixad-builder .
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
# Run local dev server in Docker # Run local dev server in Docker
devserver: docker-versioned devserver-stop devserver: docker-versioned devserver-stop
docker run -d --name pixad-dev -p 8080:8080 \ docker run -d --name pixad-dev -p 8080:8080 \
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \ -v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
pixa:latest pixad:latest
@echo "pixad running at http://localhost:8080" @echo "pixad running at http://localhost:8080"
# Stop dev server # Stop dev server
+192 -278
View File
@@ -1,9 +1,10 @@
# pixa # pixa
pixa is a GPL-3.0-licensed Go web server by [@sneak](https://sneak.berlin) that pixa is a GPL-3.0-licensed Go web server by
proxies images from upstream sources, optionally resizing or transforming them, [@sneak](https://sneak.berlin) that proxies images from upstream
and serves the results. Both source and transformed images are cached to disk so sources, optionally resizing or transforming them, and serves the
that subsequent requests are served without origin fetches or additional results. Both source and transformed images are cached to disk so that
subsequent requests are served without origin fetches or additional
processing. processing.
## Getting Started ## Getting Started
@@ -26,11 +27,12 @@ make docker
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
``` ```
A container takes its settings from environment variables (see Configuration A container takes its settings from environment variables (see
below for the list). Only `PIXA_SIGNING_KEY` is required; if it is unset the Configuration below for the list). Only `PIXA_SIGNING_KEY` is required; if
container exits at startup naming the variable. Everything else has a built-in it is unset the container exits at startup naming the variable. Everything
default. A config file mounted at `/etc/pixa/config.yml` is optional: it is read else has a built-in default. A config file mounted at `/etc/pixa/config.yml`
when present, and an environment variable wins over the same setting in it. is optional: it is read when present, and an environment variable wins over
the same setting in it.
## Deployment ## Deployment
@@ -90,42 +92,40 @@ another part of pixa failed to stop. A request not finished by then is cut off.
Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
it uses libvips through CGO; building it also needs their development files, it uses libvips through CGO; building it also needs their development files,
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these, `pkg-config` and a C compiler. `script/bootstrap` installs all of these with
as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which nix, apt, brew or apk.
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but
none of the C libraries: the checks compile pixa in Docker. Docker itself must
already be installed.
## Running under upaas ## Running under upaas
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs: What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
- **Port:** pixa listens on container port `8080`. - **Port:** pixa listens on container port `8080`.
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its database and - **Volume:** container path `/var/lib/pixa`, where pixa keeps its
cache. Creating the host directory when it is missing is upaas's job, tracked database and cache. Creating the host directory when it is missing is
in https://git.eeqj.de/sneak/upaas/issues/235. upaas's job, tracked in https://git.eeqj.de/sneak/upaas/issues/235.
- **Environment variables:** - **Environment variables:**
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs and - `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs
login, 32+ characters, for example from `openssl rand -base64 32` and login, 32+ characters, for example from
`openssl rand -base64 32`
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature, - `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
comma-separated comma-separated
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it; - `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
default 75% of (free space + what the cache holds) default 75% of (free space + what the cache holds)
- the rest are in the table under Configuration below - the rest are in the table under Configuration below
- **Health check:** the image's `HEALTHCHECK` requests - **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds `/.well-known/healthcheck.json`. upaas reads the container's health 60
after a deploy and marks the deploy failed unless it is `healthy`. The probe seconds after a deploy and marks the deploy failed unless it is
uses the port from `PORT` (default `8080`), so a port changed only in a `healthy`. The probe uses the port from `PORT` (default `8080`), so a
mounted config file is not seen by it: change the port with `PORT`. port changed only in a mounted config file is not seen by it: change
the port with `PORT`.
## Rationale ## Rationale
Image-heavy web applications need a fast, caching reverse proxy that can resize Image-heavy web applications need a fast, caching reverse proxy that
and transcode images on the fly. pixa fills that role as a single, can resize and transcode images on the fly. pixa fills that role as a
self-contained binary with no external runtime dependencies beyond libvips. It single, self-contained binary with no external runtime dependencies
supports HMAC-SHA256 signed URLs with expiration to prevent abuse, and beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
allowlisted source hosts for open access. prevent abuse, and allowlisted source hosts for open access.
## Design ## Design
@@ -134,8 +134,8 @@ allowlisted source hosts for open access.
- **Source content**: - **Source content**:
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>` `<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
- **Source metadata**: - **Source metadata**:
`<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json` (host, `<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json`
path and query, content hash, upstream status and headers, fetch time) (host, path and query, content hash, upstream status and headers, fetch time)
- **Database**: `<state_dir>/state.sqlite3` (SQLite) - **Database**: `<state_dir>/state.sqlite3` (SQLite)
- **Transformed images**: - **Transformed images**:
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`, `<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`,
@@ -144,13 +144,12 @@ allowlisted source hosts for open access.
`<ab>` and `<cd>` are the first and second pairs of characters of the file's `<ab>` and `<cd>` are the first and second pairs of characters of the file's
name. name.
Multiple source paths may reference the same content blob; the database tracks Multiple source paths may reference the same content blob; the
references rather than using filesystem refcounting. database tracks references rather than using filesystem refcounting.
Toward a target of 1-5k r/s, pixa keeps in memory the content types of
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load the 10,000 transformed images most recently cached or served, so a
Test). Toward it, pixa keeps in memory the content types of the 10,000 cache hit on one of them reads only the image file from disk and not
transformed images most recently cached or served, so a cache hit on one of them the metadata file stored beside it.
reads only the image file from disk and not the metadata file stored beside it.
### Routes ### Routes
@@ -160,8 +159,8 @@ answers any method as it answers `GET`. A browser's CORS preflight request
(`OPTIONS` with `Origin` and `Access-Control-Request-Method` headers) to any (`OPTIONS` with `Origin` and `Access-Control-Request-Method` headers) to any
path under `/v1/` answers 200, in maintenance mode too. path under `/v1/` answers 200, in maintenance mode too.
- `GET /` — the login page, or the URL generator page with a login session (see - `GET /` — the login page, or the URL generator page with a login session
Encrypted URLs). Needs: nothing. Answers: 200. (see Encrypted URLs). Needs: nothing. Answers: 200.
- `POST /` — log in with the signing key typed into the login page. Needs: the - `POST /` — log in with the signing key typed into the login page. Needs: the
login page's form (below). Answers: 303 to `/` with a login session cookie login page's form (below). Answers: 303 to `/` with a login session cookie
that lasts 30 days for the right key; 200 with the login page and an error for that lasts 30 days for the right key; 200 with the login page and an error for
@@ -175,14 +174,12 @@ path under `/v1/` answers 200, in maintenance mode too.
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>` — an image, fetched, - `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>` — an image, fetched,
resized and converted (below). Needs: a signature, unless the host is resized and converted (below). Needs: a signature, unless the host is
allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches
the image's `ETag`; 400 for a URL or parameter that is not valid, or for the the image's `ETag`; 400 for a URL or parameter that is not valid; 401 for a
format `auto` an `Accept` header that is not valid; 406 for the format `auto` missing or wrong signature, a missing `exp` or an `exp` in the past; 403 when
when `Accept` allows none of the formats it chooses from; 401 for a missing or the request's `Referer` names a host in `referer_blocklist`, checked before
wrong signature, a missing `exp` or an `exp` in the past; 403 when the the signature, the cache and the upstream fetch; 403 when the upstream host,
request's `Referer` names a host in `referer_blocklist`, checked before the or a host it redirects to, is `localhost`, ends in `.localhost` or `.local`,
signature, the cache and the upstream fetch; 403 when the upstream host, or a or has an address in a blocked network (see `blocked_networks`); 502 when the
host it redirects to, is `localhost`, ends in `.localhost` or `.local`, or has
an address in a blocked network (see `blocked_networks`); 502 when the
upstream answered with an error status, and for 5 minutes after that for the upstream answered with an error status, and for 5 minutes after that for the
same source URL; 503 when pixa is busy or in maintenance mode; 500 for any same source URL; 503 when pixa is busy or in maintenance mode; 500 for any
other failure. other failure.
@@ -192,15 +189,15 @@ path under `/v1/` answers 200, in maintenance mode too.
decrypt, or that asks for a size or fit that is not valid; 410 once it has decrypt, or that asks for a size or fit that is not valid; 410 once it has
expired; 504 when the upstream has not sent its response headers within expired; 504 when the upstream has not sent its response headers within
`upstream_fetch_timeout`, but 500 when that time runs out while the image `upstream_fetch_timeout`, but 500 when that time runs out while the image
itself is still arriving; 400 for an `Accept` header that is not valid, and itself is still arriving; 403, 502, 503 and 500 as for `/v1/image/`.
406, 403, 502, 503 and 500, as for `/v1/image/`.
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs: - `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
nothing. Answers: 200. nothing. Answers: 200.
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`, - `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
`uptime_seconds`, `uptime_human`, `version`, `appname` and `maintenance_mode`. `uptime_seconds`, `uptime_human`, `version`, `appname` and
Needs: nothing. Answers: 200, always. `maintenance_mode`. Needs: nothing. Answers: 200, always.
- `GET /static/<file>` — the stylesheet and script the login and generator pages - `GET /static/<file>` — the stylesheet and script the login and generator
load. Needs: nothing. Answers: 200, or 404 for a file that does not exist. pages load. Needs: nothing. Answers: 200, or 404 for a file that does not
exist.
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic - `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
authentication with `metrics.username` and `metrics.password`. Answers: 200; authentication with `metrics.username` and `metrics.password`. Answers: 200;
401 without them; 404 when they are not set, as the route then does not exist. 401 without them; 404 when they are not set, as the route then does not exist.
@@ -224,9 +221,9 @@ HTTP is for development on the browser's own machine: the login session cookie
is always marked `Secure`, and over plain HTTP a browser keeps such a cookie is always marked `Secure`, and over plain HTTP a browser keeps such a cookie
only for its own machine (`localhost`), if at all. A form is also refused with only for its own machine (`localhost`), if at all. A form is also refused with
403 when the page's host is not the `Host` header pixa receives, so a reverse 403 when the page's host is not the `Host` header pixa receives, so a reverse
proxy in front of pixa must pass that header on unchanged. A form body over 1 proxy in front of pixa must pass that header on unchanged. A form body over
MiB is refused with 413. The image routes answer the errors listed for them with 1 MiB is refused with 413. The image routes answer the errors listed for them
JSON holding `error`, `status` and `timestamp`. with JSON holding `error`, `status` and `timestamp`.
An image URL has this form: An image URL has this form:
@@ -238,64 +235,48 @@ Images are only fetched from origins using TLS with valid certificates, unless
`allow_http` is set: then pixa fetches every image over plain HTTP, which is for `allow_http` is set: then pixa fetches every image over plain HTTP, which is for
testing only. testing only.
A request whose query string cannot be decoded, or gives any parameter more than A request whose query string cannot be decoded, or gives any parameter more
once, is refused with 400. than once, is refused with 400.
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`, - `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
`avif`, `gif`, or `auto` (below) `avif`, `gif`
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`) - `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
- `sig` and `exp`: the signature and its expiry, needed unless the host is - `sig` and `exp`: the signature and its expiry, needed unless the host is
allowlisted (see Signature Specification) allowlisted (see Signature Specification)
- `q` and `fit`: the output quality and how the image is fitted to `<size>`, - `q` and `fit`: the output quality and how the image is fitted to `<size>`,
both optional (values under Signature Specification). Both are part of what is both optional (values under Signature Specification). Both are part of what
cached, so each value of either is a separate cached image. is cached, so each value of either is a separate cached image.
With the format `auto`, pixa chooses the format for each request from its
`Accept` header, in this order:
1. AVIF, when the header names `image/avif`;
2. WebP, when it names `image/webp`;
3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names
allows it, or when there is no `Accept` header or it is empty.
An entry with `q=0` refuses its format; other `q` values do not change the
order. AVIF and WebP must be named, as clients that cannot show them also send
`image/*` and `*/*`. pixa never sends a format the client refused: when the
header allows none of the three, the answer is 406, and a header that does not
parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The
signature, or the token of an encrypted URL, covers `auto` itself, so one URL
serves every client. Each format chosen is cached as a separate image, and every
answer that depends on `Accept` (the image, a 304, and the 400 and 406 above)
carries `Vary: Accept`, so a shared cache keeps the formats apart too.
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`. An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age` When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
is the whole seconds left until then, at most one year, so no browser or proxy `max-age` is the whole seconds left until then, at most one year, so no browser
cache keeps the image after pixa would refuse the URL. A URL with no expiry gets or proxy cache keeps the image after pixa would refuse the URL. A URL with no
one year. `immutable` only stops a client revalidating while its copy is fresh. expiry gets one year. `immutable` only stops a client revalidating while its
copy is fresh.
When several requests for the same image, size, format, quality and fit miss the When several requests for the same image, size, format, quality and fit miss
cache at once, they share one upstream fetch (or one read of the cached source) the cache at once, they share one upstream fetch (or one read of the cached
and one transcode: the first request does the work, and the others wait for its source) and one transcode: the first request does the work, and the others wait
image or its error, holding no upstream connection or processing slot of their for its image or its error, holding no upstream connection or processing slot
own. A waiting request stops waiting when its own client goes away. The work of their own. A waiting request stops waiting when its own client goes away.
goes on for the others even if the first request's client goes away, until that The work goes on for the others even if the first request's client goes away,
request's `downstream_timeout` ends. The shared fetch sends the first request's until that request's `downstream_timeout` ends. The shared fetch sends the first
ID upstream, and the lines logged for the fetch and the transcode carry that ID. request's ID upstream, and the lines logged for the fetch and the transcode
carry that ID.
The login form (`POST /`) is limited to 5 attempts per minute per client The login form (`POST /`) is limited to 5 attempts per minute per client
address, counting an IPv6 client by its /64; an attempt over the limit is address, counting an IPv6 client by its /64; an attempt over the limit is
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
address comes from `X-Forwarded-For` only when the address pixa sees for address comes from `X-Forwarded-For` only when the address pixa sees for
requests that come through the proxy is in `trusted_proxies`; otherwise all requests that come through the proxy is in `trusted_proxies`; otherwise all
users behind the proxy are counted as one client. That address is not always the users behind the proxy are counted as one client. That address is not always
proxy's own: a proxy on the Docker host that connects to pixa over `127.0.0.1` the proxy's own: a proxy on the Docker host that connects to pixa over
is seen as the gateway of the container's Docker network, such as `172.17.0.1` `127.0.0.1` is seen as the gateway of the container's Docker network, such as
on the default bridge, and one that connects through another of the host's `172.17.0.1` on the default bridge, and one that connects through another of the
addresses is seen with that address. To be sure, read it as `remoteIP` in pixa's host's addresses is seen with that address. To be sure, read it as `remoteIP` in
request log while it is not in `trusted_proxies` (see `trusted_proxies` under pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies`
Configuration). With the default `trusted_proxies` (the RFC 1918 ranges), a under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges),
client with a private address can choose the address it is counted by through a client with a private address can choose the address it is counted by through
its own `X-Forwarded-For`, whether it connects directly or through the proxy, its own `X-Forwarded-For`, whether it connects directly or through the proxy,
because its own address is trusted too. Setting `trusted_proxies` to only the because its own address is trusted too. Setting `trusted_proxies` to only the
address pixa sees for requests that come through the proxy closes this. address pixa sees for requests that come through the proxy closes this.
@@ -318,20 +299,20 @@ nor change what it asks for.
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when 3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
it expires. `<host>` is the host the page was opened on, and the URL starts it expires. `<host>` is the host the page was opened on, and the URL starts
with `http` instead while `debug` is on. The name after the token is ignored with `http` instead while `debug` is on. The name after the token is ignored
and only gives the URL a file extension, `jpg` for `orig` and `auto`. and only gives the URL a file extension, `jpg` for `orig`.
The token holds the source's host, path and query and the size, format, quality, The token holds the source's host, path and query and the size, format,
fit and expiry, encrypted with a key derived from `signing_key`. The source quality, fit and expiry, encrypted with a key derived from `signing_key`. The
URL's scheme is not kept: the image is fetched like any other (see Routes), and source URL's scheme is not kept: the image is fetched like any other (see
the blocked networks still apply. Routes), and the blocked networks still apply.
How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never. How long the URL lasts is chosen on the page, from 1 minute to 1 year, or
The expiry is fixed in the token when the URL is made and cannot be changed or never. The expiry is fixed in the token when the URL is made and cannot be
revoked afterwards. Until then the image is served with a `max-age` that ends at changed or revoked afterwards. Until then the image is served with a `max-age`
the expiry (see Routes); after it the URL answers 410 `URL has expired`. A URL that ends at the expiry (see Routes); after it the URL answers 410
made to last forever stops working only when `signing_key` changes: changing it `URL has expired`. A URL made to last forever stops working only when
makes every encrypted URL already handed out answer 400, and ends every login `signing_key` changes: changing it makes every encrypted URL already handed out
session. answer 400, and ends every login session.
### Image Metadata ### Image Metadata
@@ -350,15 +331,16 @@ turned off.
### Source Hosts ### Source Hosts
Source hosts may be allowlisted in the configuration. Non-allowlisted hosts Source hosts may be allowlisted in the configuration. Non-allowlisted
require an HMAC-SHA256 signature. hosts require an HMAC-SHA256 signature.
#### Signature Specification #### Signature Specification
Signatures use HMAC-SHA256 and include an expiration timestamp to prevent replay Signatures use HMAC-SHA256 and include an expiration timestamp to
attacks. Signatures are **exact match only**: every component (host, path, prevent replay attacks. Signatures are **exact match only**: every
query, dimensions, format, expiration, quality, fit) must match exactly what was component (host, path, query, dimensions, format, expiration, quality,
signed. No suffix matching, wildcard matching, or partial matching is supported. fit) must match exactly what was signed. No suffix matching, wildcard
matching, or partial matching is supported.
**Signed data format** (colon-separated): **Signed data format** (colon-separated):
@@ -374,26 +356,25 @@ Where:
- `width` — requested width in pixels, `0` for original - `width` — requested width in pixels, `0` for original
- `height` — requested height in pixels, `0` for original - `height` — requested height in pixels, `0` for original
- `format` — output format, one of those listed under Routes, with `original` - `format` — output format, one of those listed under Routes, with `original`
signed as `orig` and `jpg` as `jpeg`; `auto` is signed as `auto`, not as the signed as `orig` and `jpg` as `jpeg`
format chosen for the request - `expiration` — the URL's `exp` query parameter, the Unix timestamp when
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when the the signature expires; a request whose `exp` is not a whole number, an
signature expires; a request whose `exp` is not a whole number, an empty empty `exp=` included, is refused with 400
`exp=` included, is refused with 400 - `quality` — the URL's `q` query parameter, a whole number from 1 to 100,
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100, or or `85` when the URL has no `q`; a request whose `q` is anything else is
`85` when the URL has no `q`; a request whose `q` is anything else is refused refused with 400
with 400
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside, - `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
outside), or `cover` when the URL has no `fit`; a request whose `fit` is outside), or `cover` when the URL has no `fit`; a request whose `fit` is
anything else, an empty `fit=` included, is refused with 400 anything else, an empty `fit=` included, is refused with 400
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet of The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
compares it exactly, so a signature encoded without padding, as Node's compares it exactly, so a signature encoded without padding, as Node's
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401. `base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
**Example:** with the signing key `example-signing-key-for-documentation`, **Example:** with the signing key `example-signing-key-for-documentation`,
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with expiration resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
1704067200, default quality and fit: expiration 1704067200, default quality and fit:
1. Build input: 1. Build input:
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover` `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
@@ -421,29 +402,29 @@ or a `*.` wildcard, aborts startup.
### Configuration ### Configuration
Every setting can be given as an environment variable, in a YAML config file Every setting can be given as an environment variable, in a YAML config
(`--config`), or both. A variable present in the environment wins over the file, file (`--config`), or both. A variable present in the environment wins over
even when it is empty, and the file wins over the built-in default. The one the file, even when it is empty, and the file wins over the built-in
exception is a variable named in the file's `env:` section: it is set while the default. The one exception is a variable named in the file's `env:` section:
file loads, so it overrides both the environment the process was started with it is set while the file loads, so it overrides both the environment the
and the file's own key. A variable's value is parsed as the same text in the process was started with and the file's own key. A variable's value is
file would be. The three lists take comma-separated entries, with the spaces parsed as the same text in the file would be. The three lists take
around each trimmed; an empty variable is an empty list. A value that does not comma-separated entries, with the spaces around each trimmed; an empty
parse or is invalid aborts startup, naming the variable. A variable whose name variable is an empty list. A value that does not parse or is invalid aborts
starts with `PIXA_` but is not in the table below, such as a misspelled one or startup, naming the variable. A variable whose name starts with `PIXA_` but
`PIXA_PORT`, aborts startup naming it, as an unknown config key does. The one is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts
other accepted name is `PIXA_CONFIG_PATH`, the config file's path (like startup naming it, as an unknown config key does. The one other accepted
`--config`). The variables set by the file's `env:` section are checked the same name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The
way. variables set by the file's `env:` section are checked the same way.
pixa reads at most one config file: the one given with `--config` (or `-c`), pixa reads at most one config file: the one given with `--config` (or `-c`),
otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that
pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`, pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml` and `~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
`config.yaml` in the working directory. A named file that does not exist, cannot and `config.yaml` in the working directory. A named file that does not exist,
be read or does not parse aborts startup. Of the files pixa looks for on its cannot be read or does not parse aborts startup. Of the files pixa looks for on
own, only one that does not exist is passed over, without a message. One that its own, only one that does not exist is passed over, without a message. One
pixa cannot read or parse aborts startup, naming the file. So does one in a that pixa cannot read or parse aborts startup, naming the file. So does one in a
directory pixa may not enter, whether or not it is there, since pixa cannot directory pixa may not enter, whether or not it is there, since pixa cannot
tell. With no file, pixa uses the environment and the defaults. tell. With no file, pixa uses the environment and the defaults.
@@ -478,11 +459,11 @@ Key settings in more detail:
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the `Access-Control-Allow-Origin` header; no other route sends it. `*`, the
default, is any site; otherwise one `http` or `https` origin such as default, is any site; otherwise one `http` or `https` origin such as
`https://example.com`, whose host is a lowercase host name (letters, digits, `https://example.com`, whose host is a lowercase host name (letters,
hyphens and dots, with a letter in its last part) or an IP address (IPv6 in digits, hyphens and dots, with a letter in its last part) or an IP address
brackets, in its shortest form), with an optional port 1-65535 that has no (IPv6 in brackets, in its shortest form), with an optional port 1-65535
leading zero and is not the scheme's default. Any other value, including that has no leading zero and is not the scheme's default. Any other value,
another scheme such as a browser extension's, aborts startup including another scheme such as a browser extension's, aborts startup
- `allowlist_hosts` — list of allowed upstream hosts - `allowlist_hosts` — list of allowed upstream hosts
- `referer_blocklist` — list of hosts whose pages may not show pixa's images, to - `referer_blocklist` — list of hosts whose pages may not show pixa's images, to
stop other sites hotlinking them. Entries are written and matched as for stop other sites hotlinking them. Entries are written and matched as for
@@ -491,37 +472,40 @@ Key settings in more detail:
`/v1/e/` whose `Referer` header names a listed host is refused with 403 before `/v1/e/` whose `Referer` header names a listed host is refused with 403 before
its signature or token is checked and before the cache or the upstream host is its signature or token is checked and before the cache or the upstream host is
used, so it fetches nothing, and it is refused even when the image is cached. used, so it fetches nothing, and it is refused even when the image is cached.
A request with no `Referer`, or one that does not parse as a URL with a host, A request with no `Referer`, or one that does not parse as a URL
is served, as many clients send none. So this is easily got around: a site with a host, is served, as many clients send none. So this is easily got
whose pages send no `Referer` (for example with around: a site whose pages send no `Referer` (for example with
`Referrer-Policy: no-referrer`) is not stopped. It does not apply to the login `Referrer-Policy: no-referrer`) is not stopped. It does not apply to the login
and generator pages. Default: empty and generator pages. Default: empty
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added - `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT, added to the always-enforced built-in ranges (loopback, private,
benchmark, NAT64, and the like); an invalid CIDR aborts startup link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of aborts startup
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one - `trusted_proxies` — list of CIDR ranges of the reverse proxies in front
of these ranges; the logged and login-recorded client address is then the of pixa. `X-Forwarded-For` is believed only when the direct peer falls
rightmost forwarded entry that is not itself a trusted proxy. Otherwise the inside one of these ranges; the logged and login-recorded client
direct peer address is used and the header is ignored, so a client connecting address is then the rightmost forwarded entry that is not itself a
directly from an address outside these ranges cannot spoof its address. An trusted proxy. Otherwise the direct peer address is used and the header
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`, is ignored, so a client connecting directly from an address outside
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a these ranges cannot spoof its address.
private network; an explicitly empty list (`[]`) trusts no one, and an An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
explicit list replaces the default. An invalid CIDR aborts startup. Set this `172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
to the address pixa sees for requests that come through your proxy, such as proxy on a private network; an explicitly empty list (`[]`) trusts no
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else one, and an explicit list replaces the default. An invalid CIDR aborts
(see the login limit under Routes). For a proxy on the Docker host that startup. Set this to the address pixa sees for requests that come through
connects to pixa over `127.0.0.1`, that address is the gateway of the your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
container's Docker network (`172.17.0.1` on the default bridge), not the to trust nothing else (see the login limit under Routes). For a proxy on
proxy's own address; a proxy that connects through another of the host's the Docker host that connects to pixa over `127.0.0.1`, that address is the
addresses is seen with that address. To be sure which address it is, set this gateway of the container's Docker network (`172.17.0.1` on the default
to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the bridge), not the proxy's own address; a proxy that connects through another of
proxy, and read `remoteIP` in pixa's request log line for it the host's addresses is seen with that address. To be sure which address it
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host, is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
as a duration such as `30s` (the default) or `2m` through the proxy, and read `remoteIP` in pixa's request log line for it
- `upstream_max_response_size` — largest upstream response accepted, in bytes; - `upstream_fetch_timeout` — time allowed for one fetch from an upstream
default `52428800` (50 MiB). It also limits the image data pixa decodes host, as a duration such as `30s` (the default) or `2m`
- `upstream_max_response_size` — largest upstream response accepted, in
bytes; default `52428800` (50 MiB). It also limits the image data pixa
decodes
- `downstream_timeout` — time allowed for answering one client request, as a - `downstream_timeout` — time allowed for answering one client request, as a
duration; default `60s`. The upstream fetch counts toward it, and so do the duration; default `60s`. The upstream fetch counts toward it, and so do the
waits for an upstream connection and for a processing slot (up to 10 seconds waits for an upstream connection and for a processing slot (up to 10 seconds
@@ -533,11 +517,11 @@ Key settings in more detail:
so a write that finds another in progress waits up to five seconds for it so a write that finds another in progress waits up to five seconds for it
instead of failing. WAL mode comes only from the URL: keep instead of failing. WAL mode comes only from the URL: keep
`_pragma=journal_mode(WAL)` in one you set `_pragma=journal_mode(WAL)` in one you set
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk - `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
cache entirely; omitted defaults to 75% of the sum of the free space on the disk cache entirely; omitted defaults to 75% of the sum of the free space on
filesystem containing `<state_dir>/cache/` and the bytes of source and the filesystem containing `<state_dir>/cache/` and the bytes of source and
transformed images the cache already holds, worked out at startup (minimum 500 transformed images the cache already holds, worked out at startup (minimum
MiB) 500 MiB)
- `upstream_connections` — the most connections to upstream hosts at once, all - `upstream_connections` — the most connections to upstream hosts at once, all
hosts together, on top of `upstream_connections_per_host`; default `64`. A hosts together, on top of `upstream_connections_per_host`; default `64`. A
fetch holds its connection until its image has been processed. A fetch that fetch holds its connection until its image has been processed. A fetch that
@@ -552,10 +536,10 @@ Key settings in more detail:
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and - `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header `/v1/e/`) answer every request for an image with 503, a `Retry-After` header
and a JSON error body. The health check (`/.well-known/healthcheck.json`) and a JSON error body. The health check (`/.well-known/healthcheck.json`)
still answers 200 and reports `"maintenance_mode": true`. It stays 200 because still answers 200 and reports `"maintenance_mode": true`. It stays 200
the image's Docker `HEALTHCHECK` requests it: a 503 there would make the because the image's Docker `HEALTHCHECK` requests it: a 503 there would make
container unhealthy, and upaas marks a deploy failed when its container is the container unhealthy, and upaas marks a deploy failed when its container
unhealthy. The login and URL generator pages and `/metrics` keep working is unhealthy. The login and URL generator pages and `/metrics` keep working
See `configs/config.example.yml` for all options with defaults. See `configs/config.example.yml` for all options with defaults.
@@ -577,98 +561,28 @@ See `configs/config.example.yml` for all options with defaults.
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We development workflow, and the Makefile targets are thin shims that call
provide: them. We provide:
- `script/bootstrap` — install git, make, Go, Node, Yarn and prettier and - `script/bootstrap` — install all dependencies (idempotent)
download the Go modules (idempotent); with `--cgo`, the C compiler and the - `script/setup` — make a fresh clone ready for development
libvips and libheif libraries that compiling pixa needs instead of Node, Yarn (bootstrap, then install-precommit)
and prettier
- `script/setup` — make a fresh clone ready for development (bootstrap, then
install-precommit)
- `script/projectname` — output the project name ("pixa") - `script/projectname` — output the project name ("pixa")
- `script/test` — run the test suite: build the `test` phase of the - `script/test` — run the test suite
`Dockerfile`, tagged `pixa-test` - `script/lint` — run golangci-lint, always in a container (builds
- `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`, `Dockerfile.lint` when run outside one)
tagged `pixa-lint`; the linter never runs on the host - `script/fmt` — format all code (writes)
- `script/fmt` — format the Go code with gofmt and the markdown with prettier - `script/fmt-check` — check formatting (read-only)
(writes)
- `script/fmt-check` — check the same formatting (read-only), on the host
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with - `script/docker` — build the Docker image tagged via `script/projectname`
the version from `git describe`; the image's build stage depends on the `lint`
and `test` phases, so this runs them too
- `script/docker-smoke` — build the image, start it, wait for it to be healthy - `script/docker-smoke` — build the image, start it, wait for it to be healthy
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a - `script/cibuild` — CI entrypoint: `docker build .` with a new
benchmark, not part of `script/check` (see Load Test) `CHECK_EPOCH` on every run, so the Dockerfile's checks run instead of
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`), coming from the build cache, and a green run implies a green repo
then `script/check`, then build the image as `script/docker` does
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then - `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`) `script/check`)
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that
`script/precommit` runs `script/precommit`
Every `docker build` in these scripts passes `--no-cache`, so the lint and test
phases run on every build instead of coming from the build cache.
`script/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`,
`script/setup` and `script/install-precommit` are the standard copies from
`sneak/prompts`, kept identical to them. `script/fmt` and `script/fmt-check` are
the standard copies with pixa's `gofmt` step kept before prettier. prettier
formats the markdown only: not the HTML templates, as it cannot parse a Go
template action inside a tag, and not `REPO_POLICIES.md` (see
`.prettierignore`), a copy of the one in `sneak/prompts`.
## Load Test
`script/loadtest` (or `make loadtest`) measures how fast pixad answers and how
much memory it uses. It is a benchmark, not a check: `script/check` does not run
it. It needs Docker and Go.
```bash
script/loadtest # 10 seconds per scenario, 4 clients
script/loadtest 30s 32 # 30 seconds per scenario, 32 clients
```
It builds the image with `script/docker` and the load tool,
[vegeta](https://github.com/tsenart/vegeta), from a pinned commit. Each scenario
starts a new pixad container and a new origin container, `cmd/loadtest-origin`:
an upstream host that answers every path with the same generated 1600x1200 JPEG.
vegeta then sends requests from the given number of clients, each sending its
next request as soon as its last one is answered, all for an image resized to
400x300 WebP:
- `hit`: the same image every time, put in the cache first;
- `miss`: a new source image every time, so pixad fetches and converts each one;
- `herd`: each new source image once per client in a row, so that all clients
ask for it at the same time and share one fetch and one conversion (see
Routes).
pixad refuses upstream hosts with private or local addresses, so the containers
share a Docker network in `203.0.113.0/24`, a range set aside for documentation.
A second run on the same Docker host while one is going fails, as it cannot
create that network.
For each scenario the script prints vegeta's report and two lines of its own:
- `Requests [total, rate, throughput]`: the requests sent, how many were sent
per second, and how many were answered successfully per second; the last is
the number to compare with the target under Storage;
- `Latencies [min, mean, 50, 90, 95, 99, max]`: the time from sending a request
to the end of its answer; `50`, `95` and `99` are the 50th, 95th and 99th
percentiles;
- `Status Codes` and `Error Set`: anything other than `200` means the other
numbers are not for the scenario described, such as `503` when pixad was busy;
- `Bytes In`: `0`, as vegeta is told not to keep the images it receives;
- `pixad peak memory (VmHWM)`: the peak resident memory of pixad's process since
its container started, in kB; for `hit` it includes the request that put the
image in the cache;
- `requests to the origin`: the fetches pixad made: one for `hit`, one per
request for `miss`, and one per image for `herd`, that is the requests sent
divided by the number of clients.
The numbers depend on the machine and on whatever else runs on it. The first
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
nothing about the target.
## TODO ## TODO
+352 -399
View File
@@ -1,27 +1,28 @@
# Workflow # Workflow
- branch per issue from `next` * branch per issue from `next`
- do the work in Next Step * do the work in Next Step
- move Next Step to the top of Completed Steps * move Next Step to the top of Completed Steps
- `TODO.md` merges with git's union merge (`.gitattributes`), which never * `TODO.md` merges with git's union merge (`.gitattributes`), which never
reports a conflict: read the merged entries after every merge or rebase reports a conflict: read the merged entries after every merge or rebase
- move the top item of Future Steps into Next Step * move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work) * commit (`TODO.md` changes in the same commit as the work)
- open a PR based on `next` * open a PR based on `next`
- an independent reviewer who did not write the change gates it * an independent reviewer who did not write the change gates it
- the manager squash-merges the PR into `next` once review passes * the manager squash-merges the PR into `next` once review passes
- `next` stays green and mergeable to `main` at any time; only the owner merges * `next` stays green and mergeable to `main` at any time; only the owner
`next` into `main`, via the single milestone PR merges `next` into `main`, via the single milestone PR
- push * push
# Status # Status
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work
`next`, and `main` receives only the milestone PR that the owner merges. `next` lands on `next`, and `main` receives only the milestone PR that the
is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work owner merges. `next` is at the canonical `golangci-lint` v2.12.2 config
extracted the internal/magic, internal/allowlist, internal/httpfetcher, and and is green. Recent work extracted the internal/magic,
internal/signature packages. The gosec findings from the 2026-07-06 survey are internal/allowlist, internal/httpfetcher, and internal/signature
resolved. The disk cache is now size-bounded with LRU eviction packages. The gosec findings from the 2026-07-06 survey are resolved.
The disk cache is now size-bounded with LRU eviction
(`cache_max_bytes`), closing the unbounded disk growth DoS vector. (`cache_max_bytes`), closing the unbounded disk growth DoS vector.
# Next Step # Next Step
@@ -30,85 +31,11 @@ P2: security: per-IP rate limiting on the image routes
# Completed Steps # Completed Steps
- 2026-10-05 the format `auto` (closes #88): a format in the `/v1/image/` path,
an encrypted URL's token and the generator page's format choice, chosen for
each request from `Accept` once the signature or token is checked: AVIF when
the header names `image/avif`, else WebP when it names `image/webp`, else JPEG
when the first of `image/jpeg`, `image/*` and `*/*` that it names allows it,
or when it names nothing; `q=0` refuses a format. AVIF and WebP must be named,
as clients that cannot show them send the wildcards too. A header that allows
none of the three answers 406, one that does not parse 400. The signature and
the token cover `auto` itself; the cache key and `ETag` use the format chosen.
Answers from the point the format is chosen carry `Vary: Accept`, next to the
CORS `Vary: Origin`; fixed-format answers do not.
- 2026-10-05 the markdown is formatted with prettier (closes #100): `script/fmt`
and `script/fmt-check` run prettier 3.8.1, pinned in `package.json` and
`yarn.lock`, on `**/*.md` after `gofmt`, with four-space tabs and
`proseWrap: always` as `.prettierrc` says; `.prettierignore` keeps it off
`REPO_POLICIES.md`, the copy from `sneak/prompts`, and `vendor/`. Plain
`script/bootstrap` installs Node and Yarn as the one in `sneak/prompts` does
and then prettier; `script/bootstrap --cgo` does not, as the `Dockerfile`
stages that run it format nothing. The HTML templates stay unformatted:
prettier cannot parse a Go template action inside a tag. The markdown was
reflowed in a commit of its own.
- 2026-10-05 lint and tests run as the `lint` and `test` phases of the
`Dockerfile`, built with `--no-cache` (closes #202): `script/check`,
`script/cibuild`, `script/docker`, `script/lint`, `script/test`,
`script/setup` and `script/install-precommit` are now the copies from
`sneak/prompts` `main`, unchanged. The `lint` phase runs golangci-lint from
the image `REPO_POLICIES.md` names, with `libvips-dev` from `apt-get`; the
`test` phase runs the tests with a 90-second timeout; the build stage depends
on both. `Dockerfile.lint` and the `CHECK_EPOCH` build argument are gone, the
formatting check runs on the host, and `make docker-versioned` and
`make docker-test` call the scripts. `script/bootstrap`, `script/fmt`,
`script/fmt-check`, `script/precommit` and `script/projectname` stay pixa's
own. `script/bootstrap` installs git, make and Go, refreshing apt's package
lists before its first apt install; with `--cgo`, which only the `test` phase
and the build stage pass, it also installs the C compiler and the libvips and
libheif libraries. The stage that compiles still takes the version from
`git describe` when no `VERSION` is given, per
https://git.eeqj.de/sneak/pixa/issues/166, so the copied scripts' comment that
`.dockerignore` leaves out `.git` does not hold for pixa.
- 2026-10-04 load test (closes #81): `script/loadtest [duration [clients]]`
(`make loadtest`, defaults `10s` and `4`), a benchmark that `script/check`
does not run, measures three scenarios, each against a new pixad container and
a new upstream host, `cmd/loadtest-origin`: `hit` (one cached image), `miss`
(a new source image every request) and `herd` (each new source image asked for
by all clients at once). For each it prints vegeta's report (requests per
second, latency percentiles, status codes), pixad's peak resident memory and
the requests that reached the origin. `README.md` says how to run it and read
it, and keeps 1-5k r/s as a target not yet measured. First measurement, with
the defaults on a shared 48-CPU machine with other work running: a baseline
for later changes, not a test of the target. `hit` 1413 r/s, p50 0.7 ms, p95
8.7 ms, p99 44 ms, peak 53 MiB (4 clients that each wait for their answer, so
not pixad's limit); `miss` 70 r/s, p50 52 ms, p95 91 ms, p99 122 ms, peak 100
MiB, one fetch per request; `herd` 74 r/s, p50 52 ms, p95 69 ms, p99 111 ms,
peak 60 MiB, 188 fetches for 749 requests.
- 2026-10-04 the CI checkout fetches the tags (closes #208): the checkout step
in `.gitea/workflows/check.yml` sets `fetch-depth: 0`, as `REPO_POLICIES.md`
asks of a repo that takes its version from the tags, so a CI build of a tagged
commit stamps the tag from `git describe` instead of a bare commit.
- 2026-10-04 `config.yml` stays out of git and the Docker build context (closes
#212): `.gitignore` now ignores `config.yml`, the config file Getting Started
creates with the signing key, and `.dockerignore` leaves it out in every
directory and in any letter case, as it already did `config.yaml` and
`config.dev.yml`.
- 2026-10-04 local config files stay out of the Docker build context (closes
#211): `.dockerignore` now leaves out `config.yaml` and `config.dev.yml` in
every directory and in any letter case, the local config files `.gitignore`
keeps out of git because they can hold the signing key.
`configs/config.example.yml` is still sent. `config.yml`, which Getting
Started creates, is in neither file:
https://git.eeqj.de/sneak/pixa/issues/212.
- 2026-10-04 `cmd/pixad/main.go` is one call into `internal/` (closes #206): - 2026-10-04 `cmd/pixad/main.go` is one call into `internal/` (closes #206):
what it did (the command line and its `--config` flag, setting what it did (the command line and its `--config` flag, setting
`PIXA_CONFIG_PATH`, ignoring `SIGPIPE`, starting the fx app) is now `Run` in `PIXA_CONFIG_PATH`, ignoring `SIGPIPE`, starting the fx app) is now `Run` in
`internal/app`, unchanged, and `main` calls it with `Version`, which the build `internal/app`, unchanged, and `main` calls it with `Version`, which the build
still sets through `-X main.Version`. That code had no tests to move. still sets through `-X main.Version`. That code had no tests to move.
- 2026-10-04 `.gitignore` ignores `.claude/` (closes #204): the entry and its
comment are copied from the canonical `.gitignore` in `sneak/prompts`,
unanchored so it matches at every depth. `.dockerignore` already has
`.claude`.
- 2026-10-04 `.dockerignore` keeps secrets out at every depth (closes #205): the - 2026-10-04 `.dockerignore` keeps secrets out at every depth (closes #205): the
file is now the standard one from `sneak/prompts`, whose patterns match in file is now the standard one from `sneak/prompts`, whose patterns match in
every directory and, for environment files and private keys, in any letter every directory and, for environment files and private keys, in any letter
@@ -123,12 +50,13 @@ P2: security: per-IP rate limiting on the image routes
(the workflow's `script/docker-smoke` step), (the workflow's `script/docker-smoke` step),
https://git.eeqj.de/sneak/pixa/issues/204 (`.claude/` in `.gitignore`), https://git.eeqj.de/sneak/pixa/issues/204 (`.claude/` in `.gitignore`),
https://git.eeqj.de/sneak/pixa/issues/205 (`.dockerignore` patterns at every https://git.eeqj.de/sneak/pixa/issues/205 (`.dockerignore` patterns at every
depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin `cmd/pixad/main.go`) depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin
and https://git.eeqj.de/sneak/pixa/issues/208 (`fetch-depth: 0` on the CI `cmd/pixad/main.go`) and https://git.eeqj.de/sneak/pixa/issues/208
checkout, so the build sees the tags). Its rule that no build stage runs (`fetch-depth: 0` on the CI checkout, so the build sees the tags). Its rule
`git describe` is not followed: pixa takes the version from the `.git` in the that no build stage runs `git describe` is not followed: pixa takes the
build context, per https://git.eeqj.de/sneak/pixa/issues/166, as the copy on version from the `.git` in the build context, per
`sneak/prompts` `next` already says. https://git.eeqj.de/sneak/pixa/issues/166, as the copy on `sneak/prompts`
`next` already says.
- 2026-10-04 an integration test of the image proxy flow (closes #80): - 2026-10-04 an integration test of the image proxy flow (closes #80):
`TestImageProxyFlow` in `internal/server` starts the database, handlers and `TestImageProxyFlow` in `internal/server` starts the database, handlers and
middleware from the constructors `pixad` uses, with a fresh state directory, middleware from the constructors `pixad` uses, with a fresh state directory,
@@ -144,12 +72,13 @@ P2: security: per-IP rate limiting on the image routes
`httpfetcher.Config.DialContext` connects in place of the dialer that refuses `httpfetcher.Config.DialContext` connects in place of the dialer that refuses
internal addresses, the URL and redirect checks still running, and internal addresses, the URL and redirect checks still running, and
`handlers.Params.Fetcher` replaces the fetcher the handlers build. `handlers.Params.Fetcher` replaces the fetcher the handlers build.
- 2026-10-04 a URL made on the generator page with a `ttl` is tested to expire - 2026-10-04 a URL made on the generator page with a `ttl` is tested to
(closes #199): a new test in `internal/handlers` makes a URL on the generator expire (closes #199): a new test in `internal/handlers` makes a URL on the
page with a `ttl` of one second, checks that `/v1/e/` serves it at once, waits generator page with a `ttl` of one second, checks that `/v1/e/` serves it at
two seconds and checks that it then answers 410. The test waits for real, as once, waits two seconds and checks that it then answers 410. The test waits
pixa reads the clock directly when it makes and checks a URL; it waits two for real, as pixa reads the clock directly when it makes and checks a URL; it
seconds because the time a URL expires is kept in whole seconds. Test only. waits two seconds because the time a URL expires is kept in whole seconds.
Test only.
- 2026-10-04 referer blocklist (closes #90): `referer_blocklist` - 2026-10-04 referer blocklist (closes #90): `referer_blocklist`
(`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for (`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for
`allowlist_hosts` with the same matcher; an entry of either list that is `allowlist_hosts` with the same matcher; an entry of either list that is
@@ -167,29 +96,30 @@ P2: security: per-IP rate limiting on the image routes
`README.md`, the comments in `internal/config/config.go` and the startup error `README.md`, the comments in `internal/config/config.go` and the startup error
for the placeholder signing key name the new path; `scripts/manual-test.sh` for the placeholder signing key name the new path; `scripts/manual-test.sh`
and its directory are deleted, as the handler tests in `internal/handlers` and its directory are deleted, as the handler tests in `internal/handlers`
cover every check it made except two: fetching a real image from the internet, cover every check it made except two: fetching a real image from the
and a URL made on the generator page with a `ttl` answering 410 once the `ttl` internet, and a URL made on the generator page with a `ttl` answering 410 once
has passed (https://git.eeqj.de/sneak/pixa/issues/199); `CONVENTIONS.md` is the `ttl` has passed (https://git.eeqj.de/sneak/pixa/issues/199);
deleted, as `REPO_POLICIES.md` links the canonical Go HTTP server conventions. `CONVENTIONS.md` is deleted, as `REPO_POLICIES.md` links the canonical Go HTTP
server conventions.
- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes - 2026-10-04 SQLite writes no longer fail with "database is locked" (closes
#198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write #198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write
that finds another in progress on another connection waits up to five seconds that finds another in progress on another connection waits up to five seconds
for it, and the default `db_url` turns on WAL mode with for it, and the default `db_url` turns on WAL mode with
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a `_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
parameter the driver reads, so the database was never in WAL mode. parameter the driver reads, so the database was never in WAL mode.
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` only - 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup`
passes through a periodic pass (closes #189): it slept for three eviction only passes through a periodic pass (closes #189): it slept for three
intervals before writing its file, and a startup pass still running then could eviction intervals before writing its file, and a startup pass still running
adopt the file itself. It now holds the test database's only connection until then could adopt the file itself. It now holds the test database's only
the startup pass waits for it after walking the empty variant directory, connection until the startup pass waits for it after walking the empty
writes the file and lets the connection go, as variant directory, writes the file and lets the connection go, as
`TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation `TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation
pass can adopt the file. Test only. pass can adopt the file. Test only.
- 2026-10-04 logging in, logging out, the URL generator and `/v1/e/` have - 2026-10-04 logging in, logging out, the URL generator and `/v1/e/` have
handler tests (closes #77): new tests in `internal/handlers`, with no network, handler tests (closes #77): new tests in `internal/handlers`, with no
check that `GET /` without a login session shows the login form; a wrong key network, check that `GET /` without a login session shows the login form; a
shows it again with an error and sets no session cookie; the right key answers wrong key shows it again with an error and sets no session cookie; the right
303 to `/` with a session cookie marked `Secure`, `HttpOnly` and key answers 303 to `/` with a session cookie marked `Secure`, `HttpOnly` and
`SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout` `SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout`
answers 303 to `/` with an empty session cookie sent with `Max-Age=0`; answers 303 to `/` with an empty session cookie sent with `Max-Age=0`;
`POST /generate` without a login session answers 303 to `/`; `/v1/e/` serves `POST /generate` without a login session answers 303 to `/`; `/v1/e/` serves
@@ -199,11 +129,11 @@ P2: security: per-IP rate limiting on the image routes
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root - 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two `.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
branches that each add an entry at the top of Completed Steps merge without a branches that each add an entry at the top of Completed Steps merge without a
conflict and keep both entries. Git now never reports a conflict in `TODO.md`: conflict and keep both entries. Git now never reports a conflict in
a real one keeps both versions of the lines, and two entries that share an `TODO.md`: a real one keeps both versions of the lines, and two entries that
identical line can end up one inside the other, which a rebase can do to an share an identical line can end up one inside the other, which a rebase can
entry already on `next`. The Workflow above says to read the merged entries do to an entry already on `next`. The Workflow above says to read the merged
after every merge or rebase. entries after every merge or rebase.
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills - 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
(closes #184): for an omitted key, the cache works out the limit when it (closes #184): for an omitted key, the cache works out the limit when it
opens, after the database is open, as 75% of the sum of the free space on the opens, after the database is open, as 75% of the sum of the free space on the
@@ -227,10 +157,10 @@ P2: security: per-IP rate limiting on the image routes
that pixa may not enter, aborts startup naming the file, as a file that does that pixa may not enter, aborts startup naming the file, as a file that does
not parse already did. not parse already did.
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57): - 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
the deprecated `gomodguard` is switched off, so lint runs print no deprecation the deprecated `gomodguard` is switched off, so lint runs print no
warning; its successor `gomodguard_v2` runs with the shared module block list, deprecation warning; its successor `gomodguard_v2` runs with the shared
and `depguard` keeps `net/http/httptest` out of files that are not tests. The module block list, and `depguard` keeps `net/http/httptest` out of files that
tree needed no code changes. are not tests. The tree needed no code changes.
- 2026-10-04 the Content-Security-Policy allows no inline script or style - 2026-10-04 the Content-Security-Policy allows no inline script or style
(closes #125): `script-src` and `style-src` are `'self'` only. The generator (closes #125): `script-src` and `style-src` are `'self'` only. The generator
page's two inline `onclick` handlers moved into page's two inline `onclick` handlers moved into
@@ -249,30 +179,30 @@ P2: security: per-IP rate limiting on the image routes
counts, the health check for a load balancer, what a stop does and its exit counts, the health check for a load balancer, what a stop does and its exit
codes, and what running outside Docker needs; `configs/Caddyfile` is the codes, and what running outside Docker needs; `configs/Caddyfile` is the
example, checked with `caddy validate`. example, checked with `caddy validate`.
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and metrics - 2026-10-04 the metrics basic auth, CORS preflight, request logging and
recording have tests (closes #79): `MetricsAuth` on its own answers 401 with a metrics recording have tests (closes #79): `MetricsAuth` on its own answers
challenge without credentials or with a wrong username or password and lets 401 with a challenge without credentials or with a wrong username or password
the configured ones through; a preflight request gets `*` for any origin when and lets the configured ones through; a preflight request gets `*` for any
`access_control_allow_origin` is `*` and no `Access-Control-Allow-Origin` from origin when `access_control_allow_origin` is `*` and no
another origin than the configured one; a `POST /` carrying the signing key `Access-Control-Allow-Origin` from another origin than the configured one; a
leaves no trace of it in the request log line, and the login handler's own log `POST /` carrying the signing key leaves no trace of it in the request log
lines leave out the submitted key; the metrics middleware on its own records a line, and the login handler's own log lines leave out the submitted key; the
request it served, and the router records nothing while no metrics username is metrics middleware on its own records a request it served, and the router
set. Not tested: that the router puts the basic auth in front of `/metrics` records nothing while no metrics username is set. Not tested: that the router
and records requests when a metrics username is set. Only one test per package puts the basic auth in front of `/metrics` and records requests when a
can set up `/metrics`, and in `internal/server` that is metrics username is set. Only one test per package can set up `/metrics`, and
`TestMaintenanceModeKeepsOtherRoutes`, which needs the owner's approval to in `internal/server` that is `TestMaintenanceModeKeepsOtherRoutes`, which
change; #180 holds it. Tests only; the basic auth library already compares the needs the owner's approval to change; #180 holds it. Tests only; the basic
password in constant time. auth library already compares the password in constant time.
- 2026-10-04 the image route's signature check and error answers are tested - 2026-10-04 the image route's signature check and error answers are tested
(closes #76): new tests in `internal/handlers`, with no network, check the (closes #76): new tests in `internal/handlers`, with no network, check the
status and JSON error body for a missing, wrong, unpadded, upper-case or status and JSON error body for a missing, wrong, unpadded, upper-case or
expired signature on a host not on the allowlist, or a valid one sent for its expired signature on a host not on the allowlist, or a valid one sent for
parent domain, a sibling host, a subdomain or the host with another domain its parent domain, a sibling host, a subdomain or the host with another
appended (401), an unparseable path (400), `localhost` as the upstream host domain appended (401), an unparseable path (400), `localhost` as the
(403) and an upstream error (502); that an allowlisted host is served without upstream host (403) and an upstream error (502); that an allowlisted host is
a signature, another host only with a valid one; and the answers of served without a signature, another host only with a valid one; and the
`/robots.txt` and the health check. No code changes. answers of `/robots.txt` and the health check. No code changes.
- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates - 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates
(closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each (closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each
request an ID, its own `X-Request-ID` when that is at most 64 letters, digits, request an ID, its own `X-Request-ID` when that is at most 64 letters, digits,
@@ -289,26 +219,26 @@ P2: security: per-IP rate limiting on the image routes
`Vary: Accept` is left to #88. `Vary: Accept` is left to #88.
- 2026-10-04 routes, encrypted URLs and config file documented (closes #75): - 2026-10-04 routes, encrypted URLs and config file documented (closes #75):
"Routes" in `README.md` lists every route with its method, purpose, what it "Routes" in `README.md` lists every route with its method, purpose, what it
needs and the status codes it answers with, and says `q` and `fit` are part of needs and the status codes it answers with, and says `q` and `fit` are part
what is cached; "Encrypted URLs" covers logging in, making one on the of what is cached; "Encrypted URLs" covers logging in, making one on the
generator page, how long it lasts and the 410 once it has expired; generator page, how long it lasts and the 410 once it has expired;
"Configuration" gives the order in which pixa looks for its config file; "Configuration" gives the order in which pixa looks for its config file;
`config.example.yml` lists `db_url` and `env` and gives every key's default; `config.example.yml` lists `db_url` and `env` and gives every key's default;
`scripts/manual-test.sh` is left to #97. `scripts/manual-test.sh` is left to #97.
- 2026-10-04 shutdown stops cache eviction in progress (closes #102): - 2026-10-04 shutdown stops cache eviction in progress (closes #102):
`StartEviction` runs the eviction goroutine with its own context, which `StartEviction` runs the eviction goroutine with its own context, which
`StopEviction` cancels, so a pass in progress stops at its next database call, `StopEviction` cancels, so a pass in progress stops at its next database
file, row or eviction candidate instead of running to completion, and no pass call, file, row or eviction candidate instead of running to completion, and
starts after it, so a stop logs at most one warning; `StopEviction` takes a no pass starts after it, so a stop logs at most one warning;
context and, when that context ends before the goroutine exits, stops waiting `StopEviction` takes a context and, when that context ends before the
and returns its error; the handlers' stop hook passes fx's stop context, so an goroutine exits, stops waiting and returns its error; the handlers' stop hook
eviction still running when fx's stop deadline ends fails the stop and makes passes fx's stop context, so an eviction still running when fx's stop
the exit code 1. deadline ends fails the stop and makes the exit code 1.
- 2026-10-04 dead code in `internal/imgcache` is gone (closes #73): `Purge`, - 2026-10-04 dead code in `internal/imgcache` is gone (closes #73): `Purge`,
which only returned an error and which nothing called, is no longer part of which only returned an error and which nothing called, is no longer part of
the `ImageCache` interface or `Service`; the `SignatureValidator`, `Allowlist` the `ImageCache` interface or `Service`; the `SignatureValidator`,
and `Storage` interfaces, which nothing implemented or used, are deleted. `Allowlist` and `Storage` interfaces, which nothing implemented or used, are
Nothing else changes. deleted. Nothing else changes.
- 2026-10-04 upstream host semaphores and variant `.meta` files no longer - 2026-10-04 upstream host semaphores and variant `.meta` files no longer
outlive their use (closes #87): the fetcher counts the fetches holding or outlive their use (closes #87): the fetcher counts the fetches holding or
waiting for a slot of each upstream host's semaphore and removes the host's waiting for a slot of each upstream host's semaphore and removes the host's
@@ -320,20 +250,21 @@ P2: security: per-IP rate limiting on the image routes
cache directories pixa uses (`cache/sources`, `cache/metadata`, cache directories pixa uses (`cache/sources`, `cache/metadata`,
`cache/variants`) and how files are named in each, and the comments in `cache/variants`) and how files are named in each, and the comments in
`001_schema.sql` name the same paths; the routes and the signature section `001_schema.sql` name the same paths; the routes and the signature section
list the same output formats, `jpg` and `original` included; the TLS sentence list the same output formats, `jpg` and `original` included; the TLS
names `allow_http` as its exception; "Metrics" says only generic HTTP and Go sentence names `allow_http` as its exception; "Metrics" says only generic
runtime metrics exist, measured and served only when the metrics username and HTTP and Go runtime metrics exist, measured and served only when the metrics
password are set. username and password are set.
- 2026-10-03 shutdown sets the exit code and waits for image processing (closes - 2026-10-03 shutdown sets the exit code and waits for image processing
#86): fx alone handles SIGINT and SIGTERM, and the server's own signal handler (closes #86): fx alone handles SIGINT and SIGTERM, and the server's own
is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's code: 0 for a signal handler is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's
signal, 1 when the HTTP server cannot listen or the app fails to start or to code: 0 for a signal, 1 when the HTTP server cannot listen or the app fails
stop; the server's stop hook, which fx waits for, stops the HTTP server, waits to start or to stop; the server's stop hook, which fx waits for, stops the
for the images still being processed, both within 5 seconds, then flushes HTTP server, waits for the images still being processed, both within 5
Sentry; images still being processed after that are logged with their count seconds, then flushes Sentry; images still being processed after that are
and make the exit code 1; a Sentry DSN that cannot be used fails startup, so logged with their count and make the exit code 1; a Sentry DSN that cannot be
the stop hooks of what had already started run, instead of exiting the process used fails startup, so the stop hooks of what had already started run,
from a goroutine; the eviction loop is left to #102. instead of exiting the process from a goroutine; the eviction loop is left to
#102.
- 2026-10-03 every `script/cibuild` and `script/docker` run executes the checks - 2026-10-03 every `script/cibuild` and `script/docker` run executes the checks
(closes #101): the `Dockerfile` declares `CHECK_EPOCH` above `make fmt-check` (closes #101): the `Dockerfile` declares `CHECK_EPOCH` above `make fmt-check`
and `make lint` in the lint stage and above `make test` in the build stage, and `make lint` in the lint stage and above `make test` in the build stage,
@@ -348,11 +279,11 @@ P2: security: per-IP rate limiting on the image routes
upstream fetch or cached source read and one transcode through upstream fetch or cached source read and one transcode through
`golang.org/x/sync/singleflight`; the first request's processing ignores its `golang.org/x/sync/singleflight`; the first request's processing ignores its
cancellation but keeps its deadline, and the others wait for its image or cancellation but keeps its deadline, and the others wait for its image or
error holding no upstream connection or processing slot, and stop waiting when error holding no upstream connection or processing slot, and stop waiting
their own context ends; the request doing the processing waits for it even when their own context ends; the request doing the processing waits for it
then, up to its deadline; a request whose context has already ended starts even then, up to its deadline; a request whose context has already ended
nothing; each request counts one miss, and the processing counts its fetch and starts nothing; each request counts one miss, and the processing counts its
transcode once; a panic while processing is reported to Sentry when fetch and transcode once; a panic while processing is reported to Sentry when
`sentry_dsn` is set and becomes an error for every waiting request instead of `sentry_dsn` is set and becomes an error for every waiting request instead of
stopping pixad; documented in `README.md`. stopping pixad; documented in `README.md`.
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS - 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
@@ -361,20 +292,20 @@ P2: security: per-IP rate limiting on the image routes
still answers a preflight `OPTIONS` request; the login and URL generator still answers a preflight `OPTIONS` request; the login and URL generator
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`; pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
documented in `README.md` and `config.example.yml`. documented in `README.md` and `config.example.yml`.
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not `dev` - 2026-10-02 a plain `docker build .` stamps the tag or short commit, not
(closes #166): `.dockerignore` lets `.git` into the build context, without `dev` (closes #166): `.dockerignore` lets `.git` into the build context,
`.git/config`; with no `VERSION` build argument the `Dockerfile` takes the without `.git/config`; with no `VERSION` build argument the `Dockerfile`
version from `git describe --tags --always`, and fails the build if the takes the version from `git describe --tags --always`, and fails the build if
context carries `.git` and no version comes out; `ARG VERSION` has no default; the context carries `.git` and no version comes out; `ARG VERSION` has no
pixad logs its version, with its name and architecture, as its first log line default; pixad logs its version, with its name and architecture, as its first
at startup. log line at startup.
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes #159): - 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
`deploy/docker-entrypoint.sh` creates the directory if it is missing, gives #159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
the directory and everything in it to `pixad` when the directory or one of its gives the directory and everything in it to `pixad` when the directory or one
top-level entries belongs to another user or group, sets its mode to `750`, of its top-level entries belongs to another user or group, sets its mode to
then runs the server as `pixad`; data left by an earlier run under another uid `750`, then runs the server as `pixad`; data left by an earlier run under
is taken over this way; "Running under upaas" in `README.md` no longer tells another uid is taken over this way; "Running under upaas" in `README.md` no
the operator to create or chown the host directory. longer tells the operator to create or chown the host directory.
- 2026-09-29 variant content types kept in memory (closes #70): - 2026-09-29 variant content types kept in memory (closes #70):
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU `Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by (`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
@@ -413,8 +344,8 @@ P2: security: per-IP rate limiting on the image routes
`ARG VERSION` sits just above the build, so a new version reruns neither `ARG VERSION` sits just above the build, so a new version reruns neither
`script/bootstrap` nor the tests. `script/bootstrap` nor the tests.
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the - 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
migration files moved, contents unchanged, from `internal/database/schema/` to migration files moved, contents unchanged, from `internal/database/schema/`
`internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
`internal/db/migrations` package embeds them and `internal/database` reads `internal/db/migrations` package embeds them and `internal/database` reads
them through its `FS()`; the `internal/database` package itself stays; the them through its `FS()`; the `internal/database` package itself stays; the
version still comes from the filename prefix, so a database that has recorded version still comes from the filename prefix, so a database that has recorded
@@ -429,8 +360,8 @@ P2: security: per-IP rate limiting on the image routes
`=` padding kept, and gives the example's `sig` for a stated signing key. `=` padding kept, and gives the example's `sig` for a stated signing key.
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the - 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of `pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad` is the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
not owned on the host by a person's login account; the first-run step of is not owned on the host by a person's login account; the first-run step of
"Running under upaas" in `README.md` names the uid and gid. "Running under upaas" in `README.md` names the uid and gid.
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image - 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
routes build `Cache-Control` from the request's `Expires`, which an encrypted routes build `Cache-Control` from the request's `Expires`, which an encrypted
@@ -439,27 +370,29 @@ P2: security: per-IP rate limiting on the image routes
that is sooner, never negative; an allowlisted host's URL that has an `exp` that is sooner, never negative; an allowlisted host's URL that has an `exp`
follows it too; `immutable` stays, as freshness now ends at the expiry; follows it too; `immutable` stays, as freshness now ends at the expiry;
documented in `README.md`. documented in `README.md`.
- 2026-09-28 add the four settings `README.md` documented but pixa did not have, - 2026-09-28 add the four settings `README.md` documented but pixa did not
which aborted startup as unknown keys (closes #61): have, which aborted startup as unknown keys (closes #61):
`access_control_allow_origin` (default `*`, the CORS origin), `access_control_allow_origin` (default `*`, the CORS origin),
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size` `upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
(default 50 MiB) and `downstream_timeout` (default `60s`, both the server's (default 50 MiB) and `downstream_timeout` (default `60s`, both the
write timeout and the per-request timeout); each has a `PIXA_` variable; server's write timeout and the per-request timeout); each has a
durations are positive Go duration strings, the size a whole number of bytes `PIXA_` variable; durations are positive Go duration strings, the size a
up to 1 GiB, the origin `*` or one `http` or `https` origin as `README.md` whole number of bytes up to 1 GiB, the origin `*` or one `http` or
describes it; an invalid value aborts startup naming the key and the value; `https` origin as `README.md` describes it; an invalid value
documented in `config.example.yml` and `README.md`. aborts startup naming the key and the value; documented in
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` counts `config.example.yml` and `README.md`.
the cached source images and processed variants (`source_content` plus - 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
`variant_content`) and takes their size from `Cache.UsageBytes`, instead of counts the cached source images and processed variants (`source_content`
reading `request_cache` and `output_content`, which nothing writes; those two plus `variant_content`) and takes their size from `Cache.UsageBytes`,
tables are left in the schema; a disabled disk cache reports no items and no instead of reading `request_cache` and `output_content`, which nothing
size. A hit is counted even when the request context has ended. A miss is writes; those two tables are left in the schema; a disabled disk cache
counted after it is served or fails, also when the request context has ended reports no items and no size. A hit is counted even when the request
by then, with the bytes it read from upstream, so `upstream_fetch_count` and context has ended. A miss is counted after it is served or fails, also
`upstream_fetch_bytes` move, including for an upstream body that fails partway when the request context has ended by then, with the bytes it read from
or a fetched source that then fails the magic byte check; `transform_count` upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move,
counts each image the image processor transcodes. including for an upstream body that fails partway or a fetched source
that then fails the magic byte check; `transform_count` counts each image
the image processor transcodes.
- 2026-09-28 strip metadata from processed images (closes #82): every output is - 2026-09-28 strip metadata from processed images (closes #82): every output is
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
profile; the image is first turned upright with `AutoRotate` (before sizes are profile; the image is first turned upright with `AutoRotate` (before sizes are
@@ -470,201 +403,220 @@ P2: security: per-IP rate limiting on the image routes
attempts per minute per client address, and an attempt over the limit is attempts per minute per client address, and an attempt over the limit is
refused with 429 and a `Retry-After` header; the address is the one refused with 429 and a `Retry-After` header; the address is the one
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is `internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
counted by its /64, and an IPv4-mapped address as the IPv4 address it carries; counted by its /64, and an IPv4-mapped address as the IPv4 address it
the limit is a `RateLimit` middleware in `internal/middleware` on carries; the limit is a `RateLimit` middleware in `internal/middleware` on
`github.com/go-chi/httprate`, which the image routes can reuse; the library `github.com/go-chi/httprate`, which the image routes can reuse; the library
keeps counts for the current and the previous minute only; documented in keeps counts for the current and the previous minute only; documented in
`README.md`. `README.md`.
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed cache - 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
errors (closes #72): an `exp` in the URL that is not a whole number, an empty cache errors (closes #72): an `exp` in the URL that is not a whole
`exp=` included, is a 400 naming `exp` and the value, instead of being ignored number, an empty `exp=` included, is a 400 naming `exp` and the value,
and answered with 401 as if the URL had no `exp`; only an `exp` missing from instead of being ignored and answered with 401 as if the URL had no
the URL is unchanged; `README.md` says so where it documents `exp`. A failed `exp`; only an `exp` missing from the URL is unchanged; `README.md` says
variant `.meta` write, source metadata JSON write, `Stats` count query, stats so where it documents `exp`. A failed variant `.meta` write, source
counter update, negative cache write or expired negative cache delete is now metadata JSON write, `Stats` count query, stats counter update, negative
logged at `warn` with the path or key and the error, and stays non-fatal. cache write or expired negative cache delete is now logged at `warn`
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a `fit` in the with the path or key and the error, and stays non-fatal.
URL with an empty value (`fit=`) is a 400 naming `fit`, instead of being - 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a
served as `cover` and verified against a signature made for `cover`; only a `fit` in the URL with an empty value (`fit=`) is a 400 naming `fit`,
`fit` missing from the URL is still `cover`; any other value still goes instead of being served as `cover` and verified against a signature
through the existing fit-mode check; `README.md` says so where it documents made for `cover`; only a `fit` missing from the URL is still `cover`;
`fit`. any other value still goes through the existing fit-mode check;
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q` that is `README.md` says so where it documents `fit`.
not a whole number from 1 to 100, an empty `q` included, is a 400 naming `q` - 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q`
and the value, instead of being served at the default 85; the route reads `q` that is not a whole number from 1 to 100, an empty `q` included, is a
with the generator's quality check (`parseFormInt` with `minQuality` and 400 naming `q` and the value, instead of being served at the default
`maxQuality`); only a `q` missing from the URL is still 85; a query string 85; the route reads `q` with the generator's quality check
that cannot be decoded, such as `q=80%`, is a 400 showing it; any query (`parseFormInt` with `minQuality` and `maxQuality`); only a `q` missing
parameter given more than once (`q`, `fit`, `sig`, `exp` alike) is a 400 from the URL is still 85; a query string that cannot be decoded, such
naming it, so none is read from its first value only; `README.md` states the as `q=80%`, is a 400 showing it; any query parameter given more than
range and both query-string rules. once (`q`, `fit`, `sig`, `exp` alike) is a 400 naming it, so none is
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes #133): read from its first value only; `README.md` states the range and both
a variable whose name starts with `PIXA_` but is neither a setting's variable query-string rules.
nor `PIXA_CONFIG_PATH` aborts startup naming it, as an unknown config key - 2026-09-28 unknown `PIXA_` environment variables abort startup (closes
does, and `PIXA_PORT` is named with a pointer to `PORT`; the check runs after #133): a variable whose name starts with `PIXA_` but is neither a
the config file loads, so the variables the file's `env:` section sets are setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as
checked too; documented in `README.md`. an unknown config key does, and `PIXA_PORT` is named with a pointer to
- 2026-09-28 start on a fresh upaas volume (closes #129): the image starts as `PORT`; the check runs after the config file loads, so the variables
root only to give `/var/lib/pixa` to `pixad` when `pixad` does not own it the file's `env:` section sets are checked too; documented in
(`deploy/docker-entrypoint.sh`), then runs the server as `pixad` through `README.md`.
`su-exec`, so a root-owned host directory bind-mounted there no longer stops - 2026-09-28 start on a fresh upaas volume (closes #129): the image
the container at startup; `README.md` gains a "Running under upaas" section. starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + `script/lint` does not own it (`deploy/docker-entrypoint.sh`), then runs the server
(closes #104): `make lint` calls `script/lint`, the only way the linter is as `pixad` through `su-exec`, so a root-owned host directory
run; inside a container (both Dockerfiles set `container=docker`) it runs bind-mounted there no longer stops the container at startup;
`golangci-lint`, anywhere else it builds the hash-pinned `Dockerfile.lint`, `README.md` gains a "Running under upaas" section.
whose last step runs `script/lint` again; the `Dockerfile` lint stage runs - 2026-09-28 run all linting in Docker via `Dockerfile.lint` +
`make lint`; no host or nix-shell `golangci-lint` path remains `script/lint` (closes #104): `make lint` calls `script/lint`, the only
(`script/bootstrap` installs no linter); a per-run `CACHEBUST` build-arg keeps way the linter is run; inside a container (both Dockerfiles set
the lint step from being served from cache, and a tmpfs mount on that step `container=docker`) it runs `golangci-lint`, anywhere else it builds the
keeps Go's and golangci-lint's caches out of its layer, so a run leaves no hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again;
large build cache behind; `golangci-lint config verify` stays out, as it the `Dockerfile` lint stage runs `make lint`; no host or nix-shell
fetches its schema over an unpinned live HTTPS call `golangci-lint` path remains (`script/bootstrap` installs no linter);
- 2026-09-28 every setting as an environment variable (closes #128, also covers a per-run `CACHEBUST` build-arg keeps the lint step from being served
#99): each config key can be set by `PIXA_` plus the key in upper case (`.` from cache, and a tmpfs mount on that step keeps Go's and
written as `_`), and the port by `PORT`; a variable present in the golangci-lint's caches out of its layer, so a run leaves no large build
environment, even empty, wins over the config file, which wins over the cache behind; `golangci-lint config verify` stays out, as it fetches its
default; the typed getters read the variable first, so every existing check schema over an unpinned live HTTPS call
applies to it and a bad value aborts startup naming the variable; lists are - 2026-09-28 every setting as an environment variable (closes #128, also
comma-separated, and an empty variable (or `""` in the file) is an empty list; covers #99): each config key can be set by `PIXA_` plus the key in upper
the Docker image no longer bakes in `config.docker.yml` or passes `--config`, case (`.` written as `_`), and the port by `PORT`; a variable present in
and its `HEALTHCHECK` probes `PORT` (default `8080`); the config file is the environment, even empty, wins over the config file, which wins over
looked for under `/etc/pixa` and `~/.config/pixa` instead of the daemon name the default; the typed getters read the variable first, so every existing
`pixad`; documented in `README.md` and `config.example.yml`. check applies to it and a bad value aborts startup naming the variable;
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed data lists are comma-separated, and an empty variable (or `""` in the file) is
is now `host:path:query:width:height:format:expiration:quality:fit`, using an empty list; the Docker image no longer bakes in `config.docker.yml` or
`85` and `cover` when the URL has no `q` or `fit`, so one signed URL can no passes `--config`, and its `HEALTHCHECK` probes `PORT` (default `8080`);
longer be replayed across other quality and fit values to create unauthorized the config file is looked for under `/etc/pixa` and `~/.config/pixa`
cache entries and transcodes; the known-answer vectors in instead of the daemon name `pixad`; documented in `README.md` and
`internal/signature/golden_test.go` and the README signature specification `config.example.yml`.
describe the new format. - 2026-09-28 quality and fit in the URL signature (closes #60): the signed
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in the data is now `host:path:query:width:height:format:expiration:quality:fit`,
runtime stage probing `/.well-known/healthcheck.json` with busybox `wget`; using `85` and `cover` when the URL has no `q` or `fit`, so one signed
`script/docker-smoke` (`make docker-smoke`) builds the image, starts it with a URL can no longer be replayed across other quality and fit values to
throwaway `PIXA_SIGNING_KEY`, and passes only once Docker reports it healthy create unauthorized cache entries and transcodes; the known-answer
within 30 seconds, removing the container on exit; the Gitea workflow runs it vectors in `internal/signature/golden_test.go` and the README signature
after `script/cibuild`. specification describe the new format.
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
the runtime stage probing `/.well-known/healthcheck.json` with busybox
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
starts it with a throwaway `PIXA_SIGNING_KEY`, and passes only once
Docker reports it healthy within 30 seconds, removing the container on
exit; the Gitea workflow runs it after `script/cibuild`.
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a - 2026-09-21 trusted-proxy client IP resolution (closes #94): a
`trusted_proxies` config key taking a list of CIDRs, parsed by the same `trusted_proxies` config key taking a list of CIDRs, parsed by the same
`net/netip` list parser as `blocked_networks` (an invalid entry aborts startup `net/netip` list parser as `blocked_networks` (an invalid entry aborts
naming the key and value; an omitted key defaults to the RFC 1918 private startup naming the key and value; an omitted key defaults to the RFC 1918
ranges, an explicitly empty list trusts no one, and an explicit list replaces private ranges, an explicitly empty list trusts no one, and an explicit
the default); a new `internal/clientip` package resolves the client address by list replaces the default); a new
honoring `X-Forwarded-For` only when the direct peer is a trusted proxy, `internal/clientip` package resolves the client address by honoring
walking the chain right-to-left to the rightmost non-proxy entry, so a client `X-Forwarded-For` only when the direct peer is a trusted proxy, walking
connecting directly cannot spoof its address; the resolved address is stored the chain right-to-left to the rightmost non-proxy entry, so a client
in the request context by a new middleware and used by the request-logging connecting directly cannot spoof its address; the resolved address is
middleware and the login-attempt logs in place of the raw peer address; stored in the request context by a new middleware and used by the
documented in `README.md` and `config.example.yml`. request-logging middleware and the login-attempt logs in place of the
raw peer address; documented in `README.md` and `config.example.yml`.
- 2026-09-21 blocked networks configuration extending SSRF protection: a - 2026-09-21 blocked networks configuration extending SSRF protection: a
`blocked_networks` config key taking a list of CIDRs (parsed with `net/netip`, `blocked_networks` config key taking a list of CIDRs (parsed with
an invalid entry aborts startup naming the key and value), added to the `net/netip`, an invalid entry aborts startup naming the key and value),
built-in blocklist rather than replacing it; the built-in ranges extended to added to the built-in blocklist rather than replacing it; the built-in
CGNAT `100.64.0.0/10`, IETF protocol assignments `192.0.0.0/24`, benchmark ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments
`198.18.0.0/15`, and NAT64 `64:ff9b::/96` (IPv4-mapped forms covered); `192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96`
enforcement stays in the dial-time re-resolution so the DNS-rebinding window (IPv4-mapped forms covered); enforcement stays in the dial-time
remains closed; documented in `README.md` and `config.example.yml`. re-resolution so the DNS-rebinding window remains closed; documented in
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL route and the `README.md` and `config.example.yml`.
token generator (closes #62): `imgcache.ValidateDimension` alone holds the - 2026-09-21 validate dimensions and fit mode on the encrypted-URL
`MaxDimension` bound and is used by the path parser, by the new route and the token generator (closes #62): `imgcache.ValidateDimension`
`ValidateImageRequest` (which also applies `ValidateFitMode`) and by the alone holds the `MaxDimension` bound and is used by the path parser, by
generator; both the `/v1/image/` and `/v1/e/` routes call the new `ValidateImageRequest` (which also applies `ValidateFitMode`)
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a 400 and by the generator; both the `/v1/image/` and `/v1/e/` routes call
rather than an out-of-memory or a 500 from the processor; the URL generator `ValidateImageRequest`, so an over-limit size or an unknown fit mode is a
answers 400 naming the field for a `width` or `height` that is not a number or 400 rather than an out-of-memory or a 500 from the processor; the URL
fails the shared check, a `quality` that is not a number from 1 to 100, a generator answers 400 naming the field for a `width` or `height` that is
`ttl` that is not a number from 0 to the largest number of seconds the expiry not a number or fails the shared check, a `quality` that is not a number
calculation can hold, or an unknown `fit`; an empty `quality` is 85 and an from 1 to 100, a `ttl` that is not a number from 0 to the largest number
empty `ttl` never expires; the form's width and height inputs stop at 8192 of seconds the expiry calculation can hold, or an unknown `fit`; an empty
- 2026-09-21 http.Server hardening (closes #92): added `HTTPReadHeaderTimeout` `quality` is 85 and
(10s, bounds the slowloris header dribble) and `HTTPIdleTimeout` (120s, bounds an empty `ttl` never expires; the form's width and height inputs stop at
keep-alive reuse) alongside the existing timeouts and wired them onto the 8192
server; added a `LimitBody` middleware capping the two form POST bodies - 2026-09-21 http.Server hardening (closes #92): added
(`POST /`, `POST /generate`) at `MaxFormBytes` (1 MiB) and returning 413, `HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
applied ahead of the CSRF middleware so an oversized body is refused as 413 `HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
rather than being read as a missing CSRF token (403); left `WriteTimeout` at existing timeouts and wired them onto the server; added a `LimitBody`
60s unchanged middleware capping the two form POST bodies (`POST /`, `POST /generate`)
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical `.golangci.yml` at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF
(v2 schema, `default: all` minus six disabled linters, `lll` 88, tests middleware so an oversized body is refused as 413 rather than being read
included): bumped the pinned `golangci/golangci-lint:v2.12.2-alpine` image in as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged
`Dockerfile` and the release-archive sha256 pins in `script/bootstrap`; fixed - 2026-08-07 update golangci-lint to v2.12.2 with the canonical
the findings the stricter config surfaced (notably `paralleltest`, `wsl_v5`, `.golangci.yml` (v2 schema, `default: all` minus six disabled
linters, `lll` 88, tests included): bumped the pinned
`golangci/golangci-lint:v2.12.2-alpine` image in `Dockerfile` and the
release-archive sha256 pins in `script/bootstrap`; fixed the findings
the stricter config surfaced (notably `paralleltest`, `wsl_v5`,
`goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`, `testpackage` — `goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`, `testpackage` —
white-box test files renamed to `*_internal_test.go`), including #55's code white-box test files renamed to `*_internal_test.go`), including #55's
absorbed after it merged, iterating the pinned linter to `0 issues.`; no code absorbed after it merged, iterating the pinned linter to
single finding total is substantiable, since golangci-lint's `uniq-by-line` `0 issues.`; no single finding total is substantiable, since
reveals new findings on a line as others there are fixed — the documented golangci-lint's `uniq-by-line` reveals new findings on a line as
re-measurements were 81 after the #53 merge and 149 after the #55 merge; three others there are fixed — the documented re-measurements were 81 after
behavior changes, so not a pure no-op: `Cache.StoreVariant` now takes a the #53 merge and 149 after the #55 merge; three behavior changes, so
`context.Context` (`noctx`), so a cancelled request skips its best-effort not a pure no-op: `Cache.StoreVariant` now takes a `context.Context`
accounting row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and (`noctx`), so a cancelled request skips its best-effort accounting
leaked `.tmp-*.json` on failure, now fixed with explicit removals; and the row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and
`signing_key` validation error text gained `value too short: `; the eviction leaked `.tmp-*.json` on failure, now fixed with explicit removals; and
loop's uncancellable context is deferred to #102 under a the `signing_key` validation error text gained `value too short: `;
`//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep the the eviction loop's uncancellable context is deferred to #102 under a
snake_case JSON wire/disk formats unchanged; `make check` green `//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep
- 2026-08-07 implement cache size management and eviction (closes #51): new the snake_case JSON wire/disk formats unchanged; `make check` green
`cache_max_bytes` config key validated by the startup framework (explicit - 2026-08-07 implement cache size management and eviction (closes
values used exactly with no floor, `0` disables the disk cache entirely, #51): new `cache_max_bytes` config key validated by the startup
omitted defaults to max(75% of free space on the filesystem containing framework (explicit values used exactly with no floor, `0` disables
`<state_dir>/cache/`, 500 MiB), logged at startup); processed variants are now the disk cache entirely, omitted defaults to max(75% of free space
tracked in the database (a new `variant_content` table and an LRU timestamp on on the filesystem containing `<state_dir>/cache/`, 500 MiB), logged
`source_content`) so total usage is two SUMs, never a directory scan on the at startup); processed variants are now tracked in the database (a
hot path; a background goroutine evicts globally least-recently-used entries new `variant_content` table and an LRU timestamp on `source_content`)
(variants and source blobs merged) to the limit, woken by a periodic ticker so total usage is two SUMs, never a directory scan on the hot path; a
and by write-pressure notifications from stores; a source blob and ALL of its background goroutine evicts globally least-recently-used entries
`source_metadata` references are deleted in one transaction before the file is (variants and source blobs merged) to the limit, woken by a periodic
unlinked, so multi-referenced blobs are never removed while referenced and ticker and by write-pressure notifications from stores; a source
rows never point at deleted files; a startup and periodic reconciliation pass blob and ALL of its `source_metadata` references are deleted in one
adopts untracked variant files, drops rows for missing files, removes transaction before the file is unlinked, so multi-referenced blobs
unreachable source blobs, and sweeps stale temp files are never removed while referenced and rows never point at deleted
- 2026-08-07 validate configuration on startup, fail fast on bad config (closes files; a startup and periodic reconciliation pass adopts untracked
#52): a config value that is set but unparseable or invalid aborts startup variant files, drops rows for missing files, removes unreachable
naming the key and value (defaults apply only to omitted keys), unknown config source blobs, and sweeps stale temp files
keys abort startup, a malformed config file aborts instead of being skipped, - 2026-08-07 validate configuration on startup, fail fast on bad
and `state_dir` is verified creatable and writable before the listener binds config (closes #52): a config value that is set but unparseable or
- 2026-08-07 manual test pass of the auth and encrypted URL flows against a invalid aborts startup naming the key and value (defaults apply only
locally built and running `pixad` (built from `main` at `6573b9d`, port 18099, to omitted keys), unknown config keys abort startup, a malformed
local throwaway config); all six checks passed, plus all nine tests in config file aborts instead of being skipped, and `state_dir` is
`scripts/manual-test.sh` (closes #49): verified creatable and writable before the listener binds
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login` page with - 2026-08-07 manual test pass of the auth and encrypted URL flows
`name="key"` password form against a locally built and running `pixad` (built from `main` at
- [x] wrong key shows an error: POST `/` with `key=wrong-key` returned HTTP `6573b9d`, port 18099, local throwaway config); all six checks
200 login page containing "Invalid signing key" passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
- [x] correct signing key shows the generator form: POST `/` returned HTTP - [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
303 to `/` with page with `name="key"` password form
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`; GET - [x] wrong key shows an error: POST `/` with `key=wrong-key`
`/` with that cookie rendered `Pixa - URL Generator` with the returned HTTP 200 login page containing "Invalid signing key"
`/generate` form and logout link - [x] correct signing key shows the generator form: POST `/`
returned HTTP 303 to `/` with
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
GET `/` with that cookie rendered `Pixa - URL Generator` with the
`/generate` form and logout link
- [x] a generated encrypted URL serves the image: POST `/generate` - [x] a generated encrypted URL serves the image: POST `/generate`
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned HTTP (ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of 61706 HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
bytes 61706 bytes
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched after 3 s - [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
returned HTTP 410 Gone with after 3 s returned HTTP 410 Gone with
`{"error":"URL has expired","status":410,...}` `{"error":"URL has expired","status":410,...}`
- [x] logout redirects back to login: GET `/logout` returned HTTP 303 to `/` - [x] logout redirects back to login: GET `/logout` returned HTTP
with `Set-Cookie: pixa_session=; Max-Age=0`; subsequent GET `/` 303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
rendered the login form again subsequent GET `/` rendered the login form again
- 2026-08-07 fix the two remaining gosec findings (G124 in internal/session): - 2026-08-07 fix the two remaining gosec findings (G124 in
session cookies now always carry Secure/HttpOnly/SameSite=Strict on both the internal/session): session cookies now always carry
set and clear paths; `make check` green (closes #47) Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile `make check` green (closes #47)
shims, README Entrypoints section - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-04-07 extract magic byte detection into internal/magic (#42) - 2026-04-07 extract magic byte detection into internal/magic (#42)
- 2026-03-25 extract allowlist package from internal/imgcache (#41) - 2026-03-25 extract allowlist package from internal/imgcache (#41)
- 2026-03-25 move schema_migrations table creation into 000.sql (#36) - 2026-03-25 move schema_migrations table creation into 000.sql (#36)
- 2026-03-20 enforce and document exact-match-only signature verification (#40) - 2026-03-20 enforce and document exact-match-only signature
- 2026-03-20 bound imageprocessor.Process input read to prevent unbounded memory verification (#40)
use (#37); consolidate appname into an internal/globals constant (#34) - 2026-03-20 bound imageprocessor.Process input read to prevent
unbounded memory use (#37); consolidate appname into an
internal/globals constant (#34)
- 2026-03-18 parse version prefix from migration filenames (#33) - 2026-03-18 parse version prefix from migration filenames (#33)
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25) - 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for faster CI - 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
(#23) faster CI (#23)
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images, - 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
golangci-lint and gosec fixes of that date (#14); arm64 Docker build fix (#16) golangci-lint and gosec fixes of that date (#14); arm64 Docker build
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 image fix (#16)
processing items, now done) - 2026-01-08 WebP and AVIF encoding support via govips (both former P0
image processing items, now done)
# Future Steps # Future Steps
@@ -672,11 +624,12 @@ P2: security: per-IP rate limiting on the image routes
- per-origin rate limiting - per-origin rate limiting
- P2: HTTP response handling - P2: HTTP response handling
- Last-Modified headers - Last-Modified headers
- Vary header for content negotiation
- P2: auto format selection (format=auto based on Accept header)
- P2: configuration - P2: configuration
- YAML config file support - YAML config file support
- P2: operational - P2: operational
- optional Sentry error reporting - optional Sentry error reporting
- comprehensive request logging - comprehensive request logging
- Prometheus performance metrics - Prometheus performance metrics
- measure the 1k to 5k req/s target with `script/loadtest` on a machine not - load tests to verify the 1k to 5k req/s target
shared with other work
-9
View File
@@ -1,9 +0,0 @@
// Command loadtest-origin is the upstream host script/loadtest points pixad
// at; internal/loadtestorigin says what it does.
package main
import "sneak.berlin/go/pixa/internal/loadtestorigin"
func main() {
loadtestorigin.Run()
}
+1 -1
View File
@@ -371,7 +371,7 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri
// Determine file extension for the trailing filename // Determine file extension for the trailing filename
ext := format ext := format
if ext == "" || ext == "orig" || ext == "auto" { if ext == "" || ext == "orig" {
ext = "jpg" // Default extension ext = "jpg" // Default extension
} }
-131
View File
@@ -1,131 +0,0 @@
package handlers
import (
"errors"
"fmt"
"mime"
"net/http"
"strconv"
"strings"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Errors for an Accept header that an auto URL cannot be served for.
var (
errInvalidAccept = errors.New("invalid Accept header")
errNotAcceptable = errors.New(
"not acceptable: auto serves image/avif, image/webp or image/jpeg")
)
// chooseAutoFormat replaces the format auto in req with the format
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
// response, which then depends on that header. It answers 400 for an Accept
// header that is not valid and 406 for one that allows none of the formats,
// and reports whether req can be served. Any other format is left as it is.
func (s *Handlers) chooseAutoFormat(
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest,
) bool {
if req.Format != imgcache.FormatAuto {
return true
}
w.Header().Add("Vary", "Accept")
format, err := formatForAccept(strings.Join(r.Header.Values("Accept"), ","))
if errors.Is(err, errNotAcceptable) {
s.respondError(w, err.Error(), http.StatusNotAcceptable)
return false
}
if err != nil {
s.respondError(w, err.Error(), http.StatusBadRequest)
return false
}
req.Format = format
return true
}
// formatForAccept returns the format an auto URL is served in for the Accept
// header accept: AVIF when it names image/avif, else WebP when it names
// image/webp, else JPEG when its most specific entry of image/jpeg, image/*
// and */* allows it, or when it names nothing. A q of 0 refuses a format.
// AVIF and WebP must be named, as clients that cannot show them send image/*
// and */* too.
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
qualities, err := parseAccept(accept)
if err != nil {
return "", err
}
if len(qualities) == 0 {
return imgcache.FormatJPEG, nil
}
if qualities["image/avif"] > 0 {
return imgcache.FormatAVIF, nil
}
if qualities["image/webp"] > 0 {
return imgcache.FormatWebP, nil
}
// For JPEG, the most specific entry the header has decides
quality, named := qualities["image/jpeg"]
if !named {
quality, named = qualities["image/*"]
}
if !named {
quality = qualities["*/*"]
}
if quality > 0 {
return imgcache.FormatJPEG, nil
}
return "", errNotAcceptable
}
// parseAccept returns the q of each media range the Accept header accept
// names, 1 where it gives none. A media range named more than once keeps its
// lowest q, so a refusal is never overridden. A media range that does not
// parse, or a q that is not a number from 0 to 1, is an error.
func parseAccept(accept string) (map[string]float64, error) {
qualities := make(map[string]float64)
for entry := range strings.SplitSeq(accept, ",") {
// A header field list may hold empty entries
if strings.TrimSpace(entry) == "" {
continue
}
mediaRange, params, err := mime.ParseMediaType(entry)
if err != nil {
return nil, fmt.Errorf("%w: %q: %w", errInvalidAccept, entry, err)
}
quality := 1.0
if qParam, given := params["q"]; given {
quality, err = strconv.ParseFloat(qParam, 64)
inRange := quality >= 0 && quality <= 1
if err != nil || !inRange {
return nil, fmt.Errorf("%w: %q: q is not a number from 0 to 1",
errInvalidAccept, entry)
}
}
previous, named := qualities[mediaRange]
if !named || quality < previous {
qualities[mediaRange] = quality
}
}
return qualities, nil
}
@@ -1,310 +0,0 @@
package handlers
import (
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// The content types the tests below expect.
const (
avifType = "image/avif"
webpType = "image/webp"
jpegType = "image/jpeg"
jsonType = "application/json"
)
// TestFormatForAccept verifies the format chosen for the format auto from each
// Accept header below, and the error for one that allows none of AVIF, WebP
// and JPEG or is not valid.
func TestFormatForAccept(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accept string
want imgcache.ImageFormat
wantErr error
}{
{"AVIF-capable browser",
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
imgcache.FormatAVIF, nil},
{"WebP-capable browser",
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
imgcache.FormatWebP, nil},
{"WebP only", webpType, imgcache.FormatWebP, nil},
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
{"absent", "", imgcache.FormatJPEG, nil},
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
imgcache.FormatJPEG, nil},
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
{"PNG only", "image/png", "", errNotAcceptable},
{"malformed media range", "image/", "", errInvalidAccept},
{"q not a number", "image/avif;q=high", "", errInvalidAccept},
{"q above 1", "image/avif;q=2", "", errInvalidAccept},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := formatForAccept(tt.accept)
t.Logf("Accept %q: %q, %v", tt.accept, got, err)
if got != tt.want || !errors.Is(err, tt.wantErr) {
t.Errorf("formatForAccept(%q) = %q, %v, want %q, %v",
tt.accept, got, err, tt.want, tt.wantErr)
}
})
}
}
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in
// the format auto, made with h's image service and generator.
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAuto,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatAuto,
ExpiresAt: time.Now().Add(time.Minute).Unix(),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
return signedURL, "/v1/e/" + token + "/img.jpg"
}
// requestImage sends method for target to srv with an Accept header line for
// each of accept, and returns the response.
func requestImage(
t *testing.T, srv http.Handler, method, target string, accept ...string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
for _, value := range accept {
req.Header.Add("Accept", value)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
t.Logf("%s %s with Accept %q: %d, Content-Type %s, Vary %v, X-Pixa-Cache %s",
method, target, accept, rec.Code, rec.Header().Get("Content-Type"),
rec.Header().Values("Vary"), rec.Header().Get("X-Pixa-Cache"))
return rec
}
// TestFormatAuto_ChosenFromAccept requests an auto URL on each image route
// with each Accept below, and checks the answer and that it carries
// Vary: Accept. The signed URL is signed for auto, so it is valid whatever
// Accept chooses.
func TestFormatAuto_ChosenFromAccept(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, encryptedURL := autoPhotoURLs(t, h)
tests := []struct {
name string
accept []string
wantStatus int
wantType string
}{
{"AVIF accepted", []string{"image/avif,image/webp,*/*;q=0.8"},
http.StatusOK, avifType},
{"WebP accepted", []string{"image/webp,*/*;q=0.8"}, http.StatusOK, webpType},
{"no Accept", nil, http.StatusOK, jpegType},
{"two Accept lines", []string{"image/png", webpType}, http.StatusOK, webpType},
{"none of the three", []string{"image/gif"},
http.StatusNotAcceptable, jsonType},
{"not valid", []string{"image/avif;q=high"}, http.StatusBadRequest, jsonType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
for _, target := range []string{signedURL, encryptedURL} {
rec := requestImage(t, srv, http.MethodGet, target, tt.accept...)
gotType := rec.Header().Get("Content-Type")
if rec.Code != tt.wantStatus || gotType != tt.wantType {
t.Errorf("%s: %d %s, want %d %s; body %s", target,
rec.Code, gotType, tt.wantStatus, tt.wantType, rec.Body)
}
if !slices.Contains(rec.Header().Values("Vary"), "Accept") {
t.Errorf("%s: Vary = %v, want Accept in it",
target, rec.Header().Values("Vary"))
}
}
})
}
}
// TestFormatAuto_SignatureCoversAuto verifies that a /v1/image/ URL with the
// format auto is checked against a signature for auto, not for the format
// chosen: a URL signed for avif, with auto put in its path, is refused for a
// client whose Accept chooses AVIF.
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAVIF,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
if target == signedForAVIF {
t.Fatalf("no /50x50.avif? in %s", signedForAVIF)
}
rec := requestImage(t, srv, http.MethodGet, target, avifType)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
// TestFormatAuto_CachesEachFormatApart requests an auto URL for AVIF, then
// JPEG, then both again. Each format is processed once and then served from
// the cache, with an ETag of its own, so a client never gets the other format
// from the cache.
func TestFormatAuto_CachesEachFormatApart(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, _ := autoPhotoURLs(t, h)
steps := []struct {
wantType string
wantCache string
}{
{avifType, "MISS"},
{jpegType, "MISS"},
{avifType, "HIT"},
{jpegType, "HIT"},
}
etags := make(map[string]string)
for _, step := range steps {
rec := requestImage(t, srv, http.MethodGet, signedURL, step.wantType)
gotType := rec.Header().Get("Content-Type")
gotCache := rec.Header().Get("X-Pixa-Cache")
if rec.Code != http.StatusOK || gotType != step.wantType ||
gotCache != step.wantCache {
t.Fatalf("Accept %s: %d %s %s, want 200 %s %s", step.wantType,
rec.Code, gotType, gotCache, step.wantType, step.wantCache)
}
etag := rec.Header().Get("ETag")
if previous, seen := etags[gotType]; seen && previous != etag {
t.Errorf("%s ETag changed from %s to %s", gotType, previous, etag)
}
etags[gotType] = etag
}
if etags[avifType] == etags[jpegType] {
t.Errorf("AVIF and JPEG have the same ETag %s", etags[avifType])
}
}
// TestFormatAuto_Vary verifies that on each image route a HEAD answer and a
// 304 for an auto URL carry Vary: Accept, and that the answer for a URL with
// a fixed format does not.
func TestFormatAuto_Vary(t *testing.T) {
t.Parallel()
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
srv := chi.NewRouter()
srv.Get("/v1/image/*", h.HandleImage())
srv.Head("/v1/image/*", h.HandleImage())
srv.Get("/v1/e/{token}/*", h.HandleImageEnc())
srv.Head("/v1/e/{token}/*", h.HandleImageEnc())
signedURL, encryptedURL := autoPhotoURLs(t, h)
for _, urls := range [][2]string{
{signedURL, signedPhotoURL(t, h)},
{encryptedURL, encPhotoURL(t, h)},
} {
autoURL, fixedURL := urls[0], urls[1]
head := requestImage(t, srv, http.MethodHead, autoURL, webpType)
checkVaryAccept(t, head, http.StatusOK, true)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
autoURL, nil)
req.Header.Set("Accept", webpType)
req.Header.Set("If-None-Match", head.Header().Get("ETag"))
notModified := httptest.NewRecorder()
srv.ServeHTTP(notModified, req)
checkVaryAccept(t, notModified, http.StatusNotModified, true)
fixed := requestImage(t, srv, http.MethodGet, fixedURL)
checkVaryAccept(t, fixed, http.StatusOK, false)
}
}
// checkVaryAccept fails the test unless rec answered wantStatus and, as
// wantVary says, has or has not Accept in its Vary header.
func checkVaryAccept(
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantVary bool,
) {
t.Helper()
vary := rec.Header().Values("Vary")
t.Logf("status %d, Vary %v", rec.Code, vary)
if rec.Code != wantStatus {
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
}
if slices.Contains(vary, "Accept") != wantVary {
t.Errorf("Vary = %v, want Accept in it: %v", vary, wantVary)
}
}
-5
View File
@@ -43,11 +43,6 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
return return
} }
// The signature covers the format auto, not the format chosen
if !s.chooseAutoFormat(w, r, req) {
return
}
// Get cache key for logging // Get cache key for logging
cacheKey := imgcache.CacheKey(req) cacheKey := imgcache.CacheKey(req)
+1 -1
View File
@@ -30,7 +30,7 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
start := time.Now() start := time.Now()
req, ok := s.parseImageEncRequest(w, r) req, ok := s.parseImageEncRequest(w, r)
if !ok || !s.chooseAutoFormat(w, r, req) { if !ok {
return return
} }
-5
View File
@@ -22,11 +22,6 @@ const (
FormatWebP ImageFormat = "webp" FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif" FormatAVIF ImageFormat = "avif"
FormatGIF ImageFormat = "gif" FormatGIF ImageFormat = "gif"
// FormatAuto stands for AVIF, WebP or JPEG, chosen for each request
// from its Accept header once the URL's signature or token has been
// checked; it is never processed or cached as itself.
FormatAuto ImageFormat = "auto"
) )
// Size represents requested image dimensions // Size represents requested image dimensions
-2
View File
@@ -277,8 +277,6 @@ func parseFormat(s string) (ImageFormat, error) {
return FormatAVIF, nil return FormatAVIF, nil
case "gif": case "gif":
return FormatGIF, nil return FormatGIF, nil
case "auto":
return FormatAuto, nil
default: default:
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s) return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
} }
@@ -111,21 +111,6 @@ func TestParseImageURL(t *testing.T) {
} }
} }
// TestParseImageURL_AutoFormat verifies that the format auto in an image URL
// parses as FormatAuto.
func TestParseImageURL_AutoFormat(t *testing.T) {
t.Parallel()
got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto")
if err != nil {
t.Fatalf("ParseImageURL() error = %v", err)
}
if got.Format != FormatAuto {
t.Errorf("Format = %q, want %q", got.Format, FormatAuto)
}
}
func TestParseImageURL_Errors(t *testing.T) { func TestParseImageURL_Errors(t *testing.T) {
t.Parallel() t.Parallel()
-87
View File
@@ -1,87 +0,0 @@
// Package loadtestorigin is the upstream host script/loadtest points pixad
// at, run by cmd/loadtest-origin. It answers every request, whatever its path,
// with the same generated JPEG, so each new path is a new source image for
// pixad to fetch, and it logs one line per request, so its log counts pixad's
// fetches.
package loadtestorigin
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"log/slog"
"math"
"net/http"
"os"
"time"
)
const (
listenAddress = ":80"
readHeaderTimeout = 10 * time.Second
imageWidth = 1600
imageHeight = 1200
jpegQuality = 85
)
// Run makes the image and serves it on port 80 until the server fails, then
// exits the process with status 1.
func Run() {
photo, err := makeJPEG()
if err != nil {
slog.Error("cannot make the image", "error", err)
os.Exit(1)
}
server := &http.Server{
Addr: listenAddress,
Handler: newHandler(photo),
ReadHeaderTimeout: readHeaderTimeout,
}
err = server.ListenAndServe()
slog.Error("server stopped", "error", err)
os.Exit(1)
}
// newHandler answers every request with photo and logs the request's path.
func newHandler(photo []byte) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Info("request", "path", r.URL.Path)
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(photo)
})
}
// makeJPEG draws colour gradients crossed with a fine pattern, so the image
// has detail to decode and does not compress to almost nothing.
func makeJPEG() ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
// red and green count up from 0 to 255 and wrap around, along each row
// and down the image.
var green uint8
for y := range imageHeight {
var red uint8
for x := range imageWidth {
img.SetRGBA(x, y, color.RGBA{
R: red, G: green, B: red ^ green, A: math.MaxUint8,
})
red++
}
green++
}
var buf bytes.Buffer
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -1,51 +0,0 @@
package loadtestorigin
import (
"bytes"
"image/jpeg"
"net/http"
"net/http/httptest"
"testing"
)
// TestEveryPathServesTheSameJPEG checks that the origin answers any path with
// 200 and the same JPEG, so every new path script/loadtest asks pixad for is
// a valid source image.
func TestEveryPathServesTheSameJPEG(t *testing.T) {
t.Parallel()
photo, err := makeJPEG()
if err != nil {
t.Fatalf("makeJPEG: %v", err)
}
size, err := jpeg.DecodeConfig(bytes.NewReader(photo))
if err != nil {
t.Fatalf("the image does not decode as a JPEG: %v", err)
}
if size.Width != imageWidth || size.Height != imageHeight {
t.Errorf("the image is %dx%d, want %dx%d",
size.Width, size.Height, imageWidth, imageHeight)
}
handler := newHandler(photo)
for _, path := range []string{"/", "/miss/1.jpg", "/herd/2.jpg"} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("%s: status = %d, want %d", path, rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("%s: Content-Type = %q, want image/jpeg", path, ct)
}
if !bytes.Equal(rec.Body.Bytes(), photo) {
t.Errorf("%s: the body is not the image", path)
}
}
}
@@ -1,46 +0,0 @@
package server
import (
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// TestFormatAutoVaryNextToOrigin requests an image URL with the format auto
// through the server's routes and verifies that the answer carries
// Vary: Accept next to the Vary: Origin the CORS middleware sends.
func TestFormatAutoVaryNextToOrigin(t *testing.T) {
t.Parallel()
source := encodeTestPNG(t, 64, 48)
upstream := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(source)
}))
t.Cleanup(upstream.Close)
s, _, _ := startImageProxy(t, upstream)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+upstreamHost+"/photo.png/32x24.auto", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Accept", "image/webp")
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
vary := rec.Header().Values("Vary")
t.Logf("status %d, Content-Type %s, Vary %v",
rec.Code, rec.Header().Get("Content-Type"), vary)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if want := []string{"Origin", "Accept"}; !slices.Equal(vary, want) {
t.Errorf("Vary = %v, want %v", vary, want)
}
}
-1
View File
@@ -95,7 +95,6 @@
</label> </label>
<select id="format" name="format"> <select id="format" name="format">
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option> <option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
<option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (AVIF, WebP or JPEG)</option>
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option> <option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option> <option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
<option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option> <option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
-6
View File
@@ -1,6 +0,0 @@
{
"license": "GPL-3.0",
"devDependencies": {
"prettier": "3.8.1"
}
}
+8 -108
View File
@@ -3,33 +3,16 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). Node is used directly if installed; otherwise it is # make, or go). The linter is never installed on the host: golangci-lint
# installed at a pinned version via nvm (installing nvm itself first, # runs only inside a container, Dockerfile.lint or the Dockerfile lint
# from a hash-verified release archive, never curl | sh). The linter is # stage (see script/lint). A C compiler and the CGO image libraries
# never installed on the host: golangci-lint runs only in the lint phase # (pkg-config, vips, libheif) are installed for the govips bindings.
# of the Dockerfile (see script/lint). # Both Dockerfiles run this script too, so their build dependencies are
# # the ones listed here.
# script/bootstrap git, make, Go, and Node, Yarn and the
# prettier in yarn.lock for script/fmt and
# script/fmt-check: all the host needs, as
# the checks compile pixa in Docker
# script/bootstrap --cgo git, make, Go, and a C compiler and the
# CGO image libraries (pkg-config, vips,
# libheif) for the govips bindings instead
# of Node: to compile pixa, in the
# Dockerfile's test phase and build stage,
# which format nothing
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
@@ -52,10 +35,6 @@ detect_pkgmgr() {
if [ "$(id -u)" != "0" ]; then if [ "$(id -u)" != "0" ]; then
SUDO="sudo" SUDO="sudo"
fi fi
# This runs before the first install only. A fresh image, such
# as a CI runner's, has no package lists, and apt-get install
# finds no package without them.
$SUDO apt-get update
fi fi
} }
@@ -74,69 +53,6 @@ missing() {
! command -v "$1" >/dev/null 2>&1 ! command -v "$1" >/dev/null 2>&1
} }
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
# CGO dependencies for govips (image processing) # CGO dependencies for govips (image processing)
ensure_cgo_deps() { ensure_cgo_deps() {
# cgo compiles with gcc on Linux; build-base and build-essential # cgo compiles with gcc on Linux; build-base and build-essential
@@ -155,16 +71,7 @@ ensure_cgo_deps() {
fi fi
} }
usage() {
echo "usage: script/bootstrap [--cgo]" >&2
exit 2
}
main() { main() {
case "$*" in
"" | --cgo) ;;
*) usage ;;
esac
cd "$ROOT" cd "$ROOT"
# Base tooling # Base tooling
@@ -174,15 +81,8 @@ main() {
# Go toolchain # Go toolchain
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# CGO image libraries where pixa is compiled; elsewhere Node, Yarn # CGO image libraries
# and prettier ensure_cgo_deps
if [ "$*" = "--cgo" ]; then
ensure_cgo_deps
else
ensure_node
ensure_yarn
install_js_deps
fi
go mod download go mod download
+2 -3
View File
@@ -1,8 +1,7 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker # extension to scripts-to-rule-them-all. Must not modify any files.
# phases; fmt-check is native, because a formatter writes the working # Generic: usually needs no adaptation.
# tree. Must not modify any files.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+9 -20
View File
@@ -1,29 +1,18 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. The Dockerfile runs the checks
# checks out and runs this and nothing else, and script/fmt-check runs # (make fmt-check, lint, test) as build steps. This script passes a new
# the formatter on the host, which a pristine checkout cannot do. # CHECK_EPOCH on every run, so Docker runs those steps instead of
# --no-cache for the same reason as script/docker: the gate phases the # reusing cached results: a successful run means the checks ran and
# final stage depends on are RUN steps, and a cached one is a check that # passed on this tree. Generic: needs no adaptation. The Gitea workflow
# did not run. # runs this on push.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" epoch="$(date +%s)$$"
"$SCRIPT_DIR/check" docker build --build-arg CHECK_EPOCH="$epoch" .
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+6 -12
View File
@@ -1,8 +1,9 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname. Like
# --no-cache because the gate phases the final stage depends on are RUN # script/cibuild, it passes a new CHECK_EPOCH, so the build runs the
# steps, and a cached one is a check that did not run. # checks instead of reusing cached results. Generic: needs no
# adaptation.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,15 +11,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does epoch="$(date +%s)$$"
# not trip `set -e`, so the inline form degrades silently to an docker build --build-arg CHECK_EPOCH="$epoch" \
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
-20
View File
@@ -4,31 +4,11 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Formatting code..." echo "Formatting code..."
# shellcheck disable=SC2046 # word splitting of file list is wanted # shellcheck disable=SC2046 # word splitting of file list is wanted
gofmt -w $(find . -name '*.go' -not -path './vendor/*') gofmt -w $(find . -name '*.go' -not -path './vendor/*')
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
-20
View File
@@ -5,25 +5,6 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Checking formatting..." echo "Checking formatting..."
@@ -32,7 +13,6 @@ main() {
gofmt -l . | grep -v '^vendor/' gofmt -l . | grep -v '^vendor/'
exit 1 exit 1
fi fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+1 -1
View File
@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs # script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all. # script/precommit. Our own extension to scripts-to-rule-them-all.
# Generic: needs no adaptation.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit" echo "pre-commit hook installed: runs script/precommit"
+27 -13
View File
@@ -1,23 +1,37 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and # script/lint: run golangci-lint over the whole tree. This is the only
# this builds that phase alone; the linter is never installed or run on # way the linter is run, everywhere; it is never installed on the host.
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
# #
# The phase is not the last stage in the file, so it is built only when # Inside a container it runs the linter. Anywhere else it builds
# --target names it. --no-cache because a cached lint layer is a lint # Dockerfile.lint, whose last step runs this script again inside that
# that did not run. The tag makes each build replace the previous image # container.
# instead of leaving a dangling one behind. #
# Dockerfile.lint and the Dockerfile lint stage set container=docker
# (the systemd convention for marking a container) to say where we are.
# /.dockerenv cannot: it is missing inside build steps, and present on
# hosts that are themselves containers.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ if [ "${container:-}" = docker ]; then
--target lint \ # `golangci-lint config verify` is not run: it fetches its JSON
-t "$("$SCRIPT_DIR/projectname")-lint" . # schema over an unpinned live HTTPS call, which REPO_POLICIES.md
# forbids.
echo "Running linter..."
golangci-lint run --config .golangci.yml ./...
else
# A new CACHEBUST on every run means the lint step is never
# served from cache (see Dockerfile.lint). The cacheonly output
# leaves no image behind.
docker build \
--progress=plain \
--build-arg CACHEBUST="$(date +%s)-$$" \
--output=type=cacheonly \
-f Dockerfile.lint .
fi
} }
main "$@" main "$@"
-177
View File
@@ -1,177 +0,0 @@
#!/bin/sh
# script/loadtest: measure pixad's throughput, latency and peak memory.
#
# script/loadtest [duration [clients]] (defaults: 10s and 4)
#
# A benchmark, not a check: script/check does not run it. It needs Docker
# and Go. It builds the image with script/docker and builds vegeta, the
# load tool, from a pinned commit. Each scenario then gets a new pixad
# container and a new origin container (cmd/loadtest-origin, which answers
# every path with the same JPEG), and vegeta sends requests for <duration>
# from <clients> clients at once, each asking for an image resized to
# 400x300 WebP:
#
# hit the same image every time, put in the cache first
# miss a new source image every time
# herd each new source image once per client in a row, so that all
# clients ask for it at the same time
#
# For each, it prints vegeta's report, pixad's peak resident memory and
# how many requests reached the origin. README.md says how to read them.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# vegeta v12.13.0, 2026-10-04
VEGETA_COMMIT=4b240c3089fa4aa10816542d64a74294d974211f
# pixad refuses upstream hosts with private or local addresses, so the
# containers share a network in 203.0.113.0/24, a range set aside for
# documentation (RFC 5737) that pixad does not refuse and that is never
# routed on the internet.
SUBNET=203.0.113.0/24
usage() {
echo "usage: script/loadtest [duration [clients]]" >&2
exit 2
}
main() {
duration="${1:-10s}"
clients="${2:-4}"
# The duration is a whole number, not zero (vegeta takes 0 to mean no
# end), followed by ms, s, m or h.
case "$duration" in
*ms) number="${duration%ms}" ;;
*s | *m | *h) number="${duration%?}" ;;
*) usage ;;
esac
case "$number" in
"" | *[!0-9]*) usage ;;
esac
[ "$number" -gt 0 ] || usage
# The number of clients is a whole number that does not start with 0,
# which also refuses zero: vegeta reads a leading 0 as octal.
case "$clients" in
*[!0-9]* | 0*) usage ;;
esac
cd "$ROOT"
run="pixa-loadtest-$$"
tmp="$(mktemp -d)"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
"$SCRIPT_DIR/docker"
# The image's ID, so a build elsewhere that moves the tag does not
# change what a later scenario starts.
image="$(docker image inspect --format '{{.Id}}' \
"$("$SCRIPT_DIR/projectname")")"
GOBIN="$tmp" go install "github.com/tsenart/vegeta/v12@$VEGETA_COMMIT"
# The origin runs in a container, so it is built for the Docker host.
CGO_ENABLED=0 GOOS=linux \
GOARCH="$(docker version --format '{{.Server.Arch}}')" \
go build -o "$tmp/loadtest-origin" ./cmd/loadtest-origin
docker network create --subnet "$SUBNET" "$run" >/dev/null
start_containers
# Put the image the hit scenario asks for in the cache.
docker exec "$run-pixad" wget -q -O /dev/null \
"http://localhost:8080/v1/image/origin/hit.jpg/400x300.webp"
attack hit hit_targets
stop_containers
start_containers
attack miss miss_targets
stop_containers
start_containers
attack herd herd_targets
stop_containers
}
# start_containers starts a new origin and a new pixad, and waits up to 30
# seconds for pixad's health check to pass.
start_containers() {
docker run -d --name "$run-origin" \
--network "$run" --network-alias origin \
-v "$tmp/loadtest-origin:/usr/local/bin/loadtest-origin:ro" \
--entrypoint /usr/local/bin/loadtest-origin "$image" >/dev/null
docker run -d --name "$run-pixad" \
--network "$run" -p 127.0.0.1::8080 --health-interval=1s \
-e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \
-e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \
"$image" >/dev/null
waited=0
until [ "$(docker inspect --format '{{.State.Health.Status}}' \
"$run-pixad")" = healthy ]; do
if [ "$waited" -ge 30 ]; then
echo "loadtest: pixad not healthy after 30 seconds; its log:" >&2
docker logs "$run-pixad" >&2
exit 1
fi
sleep 1
waited=$((waited + 1))
done
pixa="http://$(docker port "$run-pixad" 8080/tcp)"
}
stop_containers() {
docker rm -f "$run-pixad" "$run-origin" >/dev/null
}
# attack <scenario> <targets>: send the requests <targets> prints and
# report on them.
attack() {
echo
echo "== $1: $clients clients for $duration"
"$2" | "$tmp/vegeta" attack -lazy -rate 0 -workers "$clients" \
-max-workers "$clients" -duration "$duration" -max-body 0 |
"$tmp/vegeta" report
# pixad is process 1 in its container: the entrypoint execs it.
echo "pixad peak memory (VmHWM):" \
"$(docker exec "$run-pixad" awk '/^VmHWM:/ { print $2, $3 }' \
/proc/1/status)"
echo "requests to the origin:" \
"$(docker logs "$run-origin" 2>&1 | grep -c ' request ')"
}
# The targets functions print vegeta targets until vegeta stops reading.
hit_targets() {
while :; do
echo "GET $pixa/v1/image/origin/hit.jpg/400x300.webp"
done
}
miss_targets() {
i=0
while :; do
i=$((i + 1))
echo "GET $pixa/v1/image/origin/miss/$i.jpg/400x300.webp"
done
}
herd_targets() {
i=0
while :; do
i=$((i + 1))
n=0
while [ "$n" -lt "$clients" ]; do
n=$((n + 1))
echo "GET $pixa/v1/image/origin/herd/$i.jpg/400x300.webp"
done
done
}
cleanup() {
docker rm -f "$run-pixad" "$run-origin" >/dev/null 2>&1 || :
docker network rm "$run" >/dev/null 2>&1 || :
rm -rf "$tmp"
}
main "$@"
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# script/setup: set up the repo for development after a fresh clone: # script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook. # installs dependencies (script/bootstrap) and the git pre-commit hook.
# Add any repo-specific initialization (db init, .env template) here.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+18 -10
View File
@@ -1,19 +1,27 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite. CGO dependencies (pkg-config, vips,
# and this builds that phase alone, on the same terms as script/lint: # libheif) come from nix-shell when not already available (e.g. inside
# --target because a phase that is not the last stage is built only when # a Docker build or an existing nix-shell).
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
run_with_cgo_deps() {
if command -v pkg-config >/dev/null 2>&1; then
sh -c "$1"
else
nix-shell -p pkg-config vips libheif git --run "$1"
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ echo "Running tests..."
--target test \ # Run without -v first for clean output on success; on failure rerun
-t "$("$SCRIPT_DIR/projectname")-test" . # with -v for full diagnostics, then exit non-zero (REPO_POLICIES.md
# conditional-verbose-rerun pattern). The first run already proved the
# tests broken, so the build fails even if the rerun happens to pass.
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -cover ./... || { echo '--- Rerunning with -v for details ---'; CGO_ENABLED=1 go test -timeout 30s -race -v ./...; exit 1; }"
} }
main "$@" main "$@"
-8
View File
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==