From 01823d27dba433d4412b7446cff2d56e224bb01d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 5 Oct 2026 04:24:45 +0200 Subject: [PATCH] Format the markdown with prettier in script/fmt and script/fmt-check (closes #100) script/fmt and script/fmt-check run prettier 3.8.1 on the markdown after gofmt, with the same yarn helper and arguments as the copies in sneak/prompts. prettier is pinned in package.json and yarn.lock; .prettierrc sets four-space tabs and proseWrap always, and .prettierignore keeps prettier off REPO_POLICIES.md and vendor/. Plain script/bootstrap installs Node and Yarn the way the one in sneak/prompts does; with --cgo it does not, as the Dockerfile stages that pass it format nothing. The HTML templates stay out: prettier cannot parse a Go template action inside a tag. This commit also holds the reflow that make fmt then produced (lines rewrapped, bullets as dashes, no word changed), which the PR kept as a separate commit for review. Model: opus-5-5 --- .prettierignore | 7 + .prettierrc | 4 + CLAUDE.md | 105 ++++---- README.md | 346 ++++++++++++------------ TODO.md | 682 +++++++++++++++++++++++------------------------ package.json | 6 + script/bootstrap | 101 ++++++- script/fmt | 20 ++ script/fmt-check | 20 ++ yarn.lock | 8 + 10 files changed, 710 insertions(+), 589 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 package.json create mode 100644 yarn.lock diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..888b018 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules/ +yarn.lock + +# A byte-for-byte copy of the one in sneak/prompts. +REPO_POLICIES.md + +vendor/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/CLAUDE.md b/CLAUDE.md index c9041c8..af2e77c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,73 +4,68 @@ Last Updated 2026-01-08 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 should contain one change. After each change, make a commit with a - good one-line summary. +- Never use `git add -A` - add specific changes to a deliberate commit. A commit + should contain one change. After each change, make a commit with a 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 - without asking first. In almost all cases, the code should be changed, - NOT the tests. If you think the test needs to be changed, make your case - for that and ask for permission to proceed, then stop. You need explicit - user approval to modify existing tests. (You do not need user approval - for writing NEW tests.) +- 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 + tests. If you think the test needs to be changed, make your case for that and + ask for permission to proceed, then stop. You need explicit 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 by the linter is something that legitimately needs fixing in the - code. +- When linting, assume the linter config is CORRECT, and that each item 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 `make check-fmt`. Any issues discovered MUST be resolved before - committing unless explicitly told otherwise. +- Before commits, run `make check`. This runs `make lint` and `make test` and + `make check-fmt`. Any issues discovered MUST be resolved before committing + unless explicitly told otherwise. -* When fixing a bug, write a failing test for the bug FIRST. Add - appropriate logging to the test to ensure it is written correctly. Commit - that. Then go about fixing the bug until the test passes (without - modifying the test further). Then commit that. +- When fixing a bug, write a failing test for the bug FIRST. Add appropriate + logging to the test to ensure it is written correctly. Commit that. Then go + about fixing the bug until the test passes (without modifying the test + further). Then commit that. -* 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. +- 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. -* 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 and only then can the feature branch be merged into `main` and the - branch deleted. +- 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 + and only then can the feature branch be merged into `main` and the branch + deleted. -* Write godoc documentation comments for all exported types and functions as - you go along. +- Write godoc documentation comments for all exported types and functions as you + go along. -* ALWAYS be consistent in naming. If you name something one thing in one - place, name it the EXACT SAME THING in another place. +- ALWAYS be consistent in naming. If you name something one thing in one place, + name it the EXACT SAME THING in another place. -* Be descriptive and specific in naming. `wl` is bad; - `SourceHostWhitelist` is good. `ConnsPerHost` is bad; - `MaxConnectionsPerHost` is good. +- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is + good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good. -* This is not prototype or teaching code - this is designed for production. - Any security issues (such as denial of service) or other web - vulnerabilities are P1 bugs and must be added to TODO.md at the top. +- This is not prototype or teaching code - this is designed for production. Any + security issues (such as denial of service) or other web 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 instructed. We need working implementations. +- As this is production code, no stubbing of implementations unless specifically + instructed. We need working implementations. -* 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 is not supported, return an error - do NOT silently output PNG - instead. If a user specifies fit=invalid and that fit mode doesn't exist, - return an error - do NOT silently default to "cover". Silent fallbacks - violate the principle of least surprise and mask bugs. The only acceptable - defaults are for OMITTED parameters, never for INVALID explicit values. +- 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 + is not supported, return an error - do NOT silently output PNG instead. If a + user specifies fit=invalid and that fit mode doesn't exist, return an error - + do NOT silently default to "cover". Silent fallbacks violate the principle of + least surprise and mask bugs. The only acceptable defaults are for OMITTED + parameters, never for INVALID explicit values. -* Avoid vendoring deps unless specifically instructed to. NEVER commit - the vendor directory, NEVER commit compiled binaries. If these - directories or files exist, add them to .gitignore (and commit the - .gitignore) if they are not already in there. Keep the entire git - repository (with history) small - under 20MiB, unless you specifically - must commit larger files (e.g. test fixture example media files). Only - OUR source code and immediately supporting files (such as test examples) - goes into the repo/history. +- Avoid vendoring deps unless specifically instructed to. NEVER commit the + vendor directory, NEVER commit compiled binaries. If these directories or + files exist, add them to .gitignore (and commit the .gitignore) if they are + not already in there. Keep the entire git repository (with history) small - + under 20MiB, unless you specifically must commit larger files (e.g. test + fixture example media files). Only OUR source code and immediately supporting + files (such as test examples) goes into the repo/history. diff --git a/README.md b/README.md index ce00d83..95d9079 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # pixa -pixa is a GPL-3.0-licensed Go web server by -[@sneak](https://sneak.berlin) that proxies images from upstream -sources, optionally resizing or transforming them, and serves the -results. Both source and transformed images are cached to disk so that -subsequent requests are served without origin fetches or additional +pixa is a GPL-3.0-licensed Go web server by [@sneak](https://sneak.berlin) that +proxies images from upstream sources, optionally resizing or transforming them, +and serves the results. Both source and transformed images are cached to disk so +that subsequent requests are served without origin fetches or additional processing. ## Getting Started @@ -27,12 +26,11 @@ make docker 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 below for the list). Only `PIXA_SIGNING_KEY` is required; if -it is unset the container exits at startup naming the variable. Everything -else has a built-in default. A config file mounted at `/etc/pixa/config.yml` -is optional: it is read when present, and an environment variable wins over -the same setting in it. +A container takes its settings from environment variables (see Configuration +below for the list). Only `PIXA_SIGNING_KEY` is required; if it is unset the +container exits at startup naming the variable. Everything else has a built-in +default. A config file mounted at `/etc/pixa/config.yml` is optional: it is read +when present, and an environment variable wins over the same setting in it. ## Deployment @@ -94,41 +92,40 @@ 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, `pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these, as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which -`script/setup` and `script/cibuild` run, installs only git, make and Go: the -checks compile pixa in Docker, so the host needs none of the C libraries. Docker -itself must already be installed. +`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 What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs: - **Port:** pixa listens on container port `8080`. -- **Volume:** container path `/var/lib/pixa`, where pixa keeps its - database and cache. Creating the host directory when it is missing is - upaas's job, tracked in https://git.eeqj.de/sneak/upaas/issues/235. +- **Volume:** container path `/var/lib/pixa`, where pixa keeps its database and + cache. Creating the host directory when it is missing is upaas's job, tracked + in https://git.eeqj.de/sneak/upaas/issues/235. - **Environment variables:** - - `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs - and login, 32+ characters, for example from - `openssl rand -base64 32` + - `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs and + login, 32+ characters, for example from `openssl rand -base64 32` - `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature, comma-separated - `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it; default 75% of (free space + what the cache holds) - the rest are in the table under Configuration below - **Health check:** the image's `HEALTHCHECK` requests - `/.well-known/healthcheck.json`. upaas reads the container's health 60 - seconds after a deploy and marks the deploy failed unless it is - `healthy`. The probe uses the port from `PORT` (default `8080`), so a - port changed only in a mounted config file is not seen by it: change - the port with `PORT`. + `/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds + after a deploy and marks the deploy failed unless it is `healthy`. The probe + uses the port from `PORT` (default `8080`), so a port changed only in a + mounted config file is not seen by it: change the port with `PORT`. ## Rationale -Image-heavy web applications need a fast, caching reverse proxy that -can resize and transcode images on the fly. pixa fills that role as a -single, self-contained binary with no external runtime dependencies -beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to -prevent abuse, and allowlisted source hosts for open access. +Image-heavy web applications need a fast, caching reverse proxy that can resize +and transcode images on the fly. pixa fills that role as a single, +self-contained binary with no external runtime dependencies beyond libvips. It +supports HMAC-SHA256 signed URLs with expiration to prevent abuse, and +allowlisted source hosts for open access. ## Design @@ -137,8 +134,8 @@ prevent abuse, and allowlisted source hosts for open access. - **Source content**: `/cache/sources///` - **Source metadata**: - `/cache/metadata//.json` - (host, path and query, content hash, upstream status and headers, fetch time) + `/cache/metadata//.json` (host, + path and query, content hash, upstream status and headers, fetch time) - **Database**: `/state.sqlite3` (SQLite) - **Transformed images**: `/cache/variants///`, @@ -147,8 +144,8 @@ prevent abuse, and allowlisted source hosts for open access. `` and `` are the first and second pairs of characters of the file's name. -Multiple source paths may reference the same content blob; the -database tracks references rather than using filesystem refcounting. +Multiple source paths may reference the same content blob; the database tracks +references rather than using filesystem refcounting. pixa's target is 1-5k r/s, which has not been measured at that rate (see Load Test). Toward it, pixa keeps in memory the content types of the 10,000 @@ -163,8 +160,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 path under `/v1/` answers 200, in maintenance mode too. -- `GET /` — the login page, or the URL generator page with a login session - (see Encrypted URLs). Needs: nothing. Answers: 200. +- `GET /` — the login page, or the URL generator page with a login session (see + Encrypted URLs). Needs: nothing. Answers: 200. - `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 that lasts 30 days for the right key; 200 with the login page and an error for @@ -197,11 +194,10 @@ path under `/v1/` answers 200, in maintenance mode too. - `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs: nothing. Answers: 200. - `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`, - `uptime_seconds`, `uptime_human`, `version`, `appname` and - `maintenance_mode`. Needs: nothing. Answers: 200, always. -- `GET /static/` — the stylesheet and script the login and generator - pages load. Needs: nothing. Answers: 200, or 404 for a file that does not - exist. + `uptime_seconds`, `uptime_human`, `version`, `appname` and `maintenance_mode`. + Needs: nothing. Answers: 200, always. +- `GET /static/` — the stylesheet and script the login and generator pages + load. Needs: nothing. Answers: 200, or 404 for a file that does not exist. - `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic 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. @@ -225,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 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 -proxy in front of pixa must pass that header on unchanged. A form body over -1 MiB is refused with 413. The image routes answer the errors listed for them -with JSON holding `error`, `status` and `timestamp`. +proxy in front of pixa must pass that header on unchanged. A form body over 1 +MiB is refused with 413. The image routes answer the errors listed for them with +JSON holding `error`, `status` and `timestamp`. An image URL has this form: @@ -239,8 +235,8 @@ 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 testing only. -A request whose query string cannot be decoded, or gives any parameter more -than once, is refused with 400. +A request whose query string cannot be decoded, or gives any parameter more than +once, is refused with 400. - ``: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`, `avif`, `gif` @@ -248,39 +244,37 @@ than once, is refused with 400. - `sig` and `exp`: the signature and its expiry, needed unless the host is allowlisted (see Signature Specification) - `q` and `fit`: the output quality and how the image is fitted to ``, - both optional (values under Signature Specification). Both are part of what - is cached, so each value of either is a separate cached image. + both optional (values under Signature Specification). Both are part of what is + cached, so each value of either is a separate cached image. An image is served with `Cache-Control: public, max-age=, immutable`. -When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), -`max-age` is the whole seconds left until then, at most one year, so no browser -or proxy cache keeps the image after pixa would refuse the URL. A URL with no -expiry gets one year. `immutable` only stops a client revalidating while its -copy is fresh. +When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age` +is the whole seconds left until then, at most one year, so no browser or proxy +cache keeps the image after pixa would refuse the URL. A URL with no 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 cache at once, they share one upstream fetch (or one read of the cached -source) and one transcode: the first request does the work, and the others wait -for its image or its error, holding no upstream connection or processing slot -of their own. A waiting request stops waiting when its own client goes away. -The work goes on for the others even if the first request's client goes away, -until that request's `downstream_timeout` ends. The shared fetch sends the first -request's ID upstream, and the lines logged for the fetch and the transcode -carry that ID. +When several requests for the same image, size, format, quality and fit miss the +cache at once, they share one upstream fetch (or one read of the cached source) +and one transcode: the first request does the work, and the others wait for its +image or its error, holding no upstream connection or processing slot of their +own. A waiting request stops waiting when its own client goes away. The work +goes on for the others even if the first request's client goes away, until that +request's `downstream_timeout` ends. The shared fetch sends the first 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 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 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 -users behind the proxy are counted as one client. That address is not always -the proxy's own: a proxy on the Docker host that connects to pixa over -`127.0.0.1` is seen as the gateway of the container's Docker network, such as -`172.17.0.1` on the default bridge, and one that connects through another of the -host's addresses is seen with that address. To be sure, read it as `remoteIP` in -pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies` -under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges), -a client with a private address can choose the address it is counted by through +users behind the proxy are counted as one client. That address is not always the +proxy's own: a proxy on the Docker host that connects to pixa over `127.0.0.1` +is seen as the gateway of the container's Docker network, such as `172.17.0.1` +on the default bridge, and one that connects through another of the host's +addresses is seen with that address. To be sure, read it as `remoteIP` in pixa's +request log while it is not in `trusted_proxies` (see `trusted_proxies` under +Configuration). With the default `trusted_proxies` (the RFC 1918 ranges), 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, 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. @@ -305,18 +299,18 @@ nor change what it asks for. 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`. -The token holds the source's host, path and query and the size, format, -quality, fit and expiry, encrypted with a key derived from `signing_key`. The -source URL's scheme is not kept: the image is fetched like any other (see -Routes), and the blocked networks still apply. +The token holds the source's host, path and query and the size, format, quality, +fit and expiry, encrypted with a key derived from `signing_key`. The source +URL's scheme is not kept: the image is fetched like any other (see 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. The expiry is fixed in the token when the URL is made and cannot be -changed or revoked afterwards. Until then the image is served with a `max-age` -that ends at the expiry (see Routes); after it the URL answers 410 -`URL has expired`. A URL made to last forever stops working only when -`signing_key` changes: changing it makes every encrypted URL already handed out -answer 400, and ends every login session. +How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never. +The expiry is fixed in the token when the URL is made and cannot be changed or +revoked afterwards. Until then the image is served with a `max-age` that ends at +the expiry (see Routes); after it the URL answers 410 `URL has expired`. A URL +made to last forever stops working only when `signing_key` changes: changing it +makes every encrypted URL already handed out answer 400, and ends every login +session. ### Image Metadata @@ -335,16 +329,15 @@ turned off. ### Source Hosts -Source hosts may be allowlisted in the configuration. Non-allowlisted -hosts require an HMAC-SHA256 signature. +Source hosts may be allowlisted in the configuration. Non-allowlisted hosts +require an HMAC-SHA256 signature. #### Signature Specification -Signatures use HMAC-SHA256 and include an expiration timestamp to -prevent replay attacks. Signatures are **exact match only**: every -component (host, path, query, dimensions, format, expiration, quality, -fit) must match exactly what was signed. No suffix matching, wildcard -matching, or partial matching is supported. +Signatures use HMAC-SHA256 and include an expiration timestamp to prevent replay +attacks. Signatures are **exact match only**: every component (host, path, +query, dimensions, format, expiration, quality, fit) must match exactly what was +signed. No suffix matching, wildcard matching, or partial matching is supported. **Signed data format** (colon-separated): @@ -361,24 +354,24 @@ Where: - `height` — requested height in pixels, `0` for original - `format` — output format, one of those listed under Routes, with `original` signed as `orig` and `jpg` as `jpeg` -- `expiration` — the URL's `exp` query parameter, the Unix timestamp when - the signature expires; a request whose `exp` is not a whole number, an - empty `exp=` included, is refused with 400 -- `quality` — the URL's `q` query parameter, a whole number from 1 to 100, - or `85` when the URL has no `q`; a request whose `q` is anything else is - refused with 400 +- `expiration` — the URL's `exp` query parameter, the Unix timestamp when the + signature expires; a request whose `exp` is not a whole number, an empty + `exp=` included, is refused with 400 +- `quality` — the URL's `q` query parameter, a whole number from 1 to 100, or + `85` when the URL has no `q`; a request whose `q` is anything else is refused + with 400 - `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 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 RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa +The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet 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 `base64url` and Go's `base64.RawURLEncoding` do, is refused with 401. **Example:** with the signing key `example-signing-key-for-documentation`, -resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with -expiration 1704067200, default quality and fit: +resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with expiration +1704067200, default quality and fit: 1. Build input: `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover` @@ -406,29 +399,29 @@ or a `*.` wildcard, aborts startup. ### Configuration -Every setting can be given as an environment variable, in a YAML config -file (`--config`), or both. A variable present in the environment wins over -the file, even when it is empty, and the file wins over the built-in -default. The one exception is a variable named in the file's `env:` section: -it is set while the file loads, so it overrides both the environment the -process was started with and the file's own key. A variable's value is -parsed as the same text in the file would be. The three lists take -comma-separated entries, with the spaces around each trimmed; an empty -variable is an empty list. A value that does not parse or is invalid aborts -startup, naming the variable. A variable whose name starts with `PIXA_` but -is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts -startup naming it, as an unknown config key does. The one other accepted -name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The -variables set by the file's `env:` section are checked the same way. +Every setting can be given as an environment variable, in a YAML config file +(`--config`), or both. A variable present in the environment wins over the file, +even when it is empty, and the file wins over the built-in default. The one +exception is a variable named in the file's `env:` section: it is set while the +file loads, so it overrides both the environment the process was started with +and the file's own key. A variable's value is parsed as the same text in the +file would be. The three lists take comma-separated entries, with the spaces +around each trimmed; an empty variable is an empty list. A value that does not +parse or is invalid aborts startup, naming the variable. A variable whose name +starts with `PIXA_` but is not in the table below, such as a misspelled one or +`PIXA_PORT`, aborts startup naming it, as an unknown config key does. The one +other accepted name is `PIXA_CONFIG_PATH`, the config file's path (like +`--config`). The 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`), otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`, -`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml` -and `config.yaml` in the working directory. A named file that does not exist, -cannot be read or does not parse aborts startup. Of the files pixa looks for on -its own, only one that does not exist is passed over, without a message. One -that pixa cannot read or parse aborts startup, naming the file. So does one in a +`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml` and +`config.yaml` in the working directory. A named file that does not exist, cannot +be read or does not parse aborts startup. Of the files pixa looks for on its +own, only one that does not exist is passed over, without a message. One 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 tell. With no file, pixa uses the environment and the defaults. @@ -463,11 +456,11 @@ Key settings in more detail: of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS `Access-Control-Allow-Origin` header; no other route sends it. `*`, the default, is any site; otherwise one `http` or `https` origin such as - `https://example.com`, whose host is a lowercase host name (letters, - digits, hyphens and dots, with a letter in its last part) or an IP address - (IPv6 in brackets, in its shortest form), with an optional port 1-65535 - that has no leading zero and is not the scheme's default. Any other value, - including another scheme such as a browser extension's, aborts startup + `https://example.com`, whose host is a lowercase host name (letters, digits, + hyphens and dots, with a letter in its last part) or an IP address (IPv6 in + brackets, in its shortest form), with an optional port 1-65535 that has no + leading zero and is not the scheme's default. Any other value, including + another scheme such as a browser extension's, aborts startup - `allowlist_hosts` — list of allowed upstream hosts - `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 @@ -476,40 +469,37 @@ Key settings in more detail: `/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 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, is served, as many clients send none. So this is easily got - around: a site whose pages send no `Referer` (for example with + A request with no `Referer`, or one that does not parse as a URL with a host, + is served, as many clients send none. So this is easily got 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 and generator pages. Default: empty -- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, - added to the always-enforced built-in ranges (loopback, private, - link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR - aborts startup -- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front - of pixa. `X-Forwarded-For` is believed only when the direct peer falls - inside one of these ranges; the logged and login-recorded client - address is then the rightmost forwarded entry that is not itself a - trusted proxy. Otherwise the direct peer address is used and the header - is ignored, so a client connecting directly from an address outside - these ranges cannot spoof its address. - An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`, - `172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a - proxy on a private network; an explicitly empty list (`[]`) trusts no - one, and an explicit list replaces the default. An invalid CIDR aborts - startup. Set this to the address pixa sees for requests that come through - your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or - to trust nothing else (see the login limit under Routes). For a proxy on - the Docker host that connects to pixa over `127.0.0.1`, that address is the - gateway of the container's Docker network (`172.17.0.1` on the default - bridge), not the proxy's own address; a proxy that connects through another of - the host's addresses is seen with that address. To be sure which address it - is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request - through the proxy, and read `remoteIP` in pixa's request log line for it -- `upstream_fetch_timeout` — time allowed for one fetch from an upstream - 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 +- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added + to the always-enforced built-in ranges (loopback, private, link-local, CGNAT, + benchmark, NAT64, and the like); an invalid CIDR aborts startup +- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of + pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one + of these ranges; the logged and login-recorded client address is then the + rightmost forwarded entry that is not itself a trusted proxy. Otherwise the + direct peer address is used and the header is ignored, so a client connecting + directly from an address outside these ranges cannot spoof its address. An + omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`, + `172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a + private network; an explicitly empty list (`[]`) trusts no one, and an + explicit list replaces the default. An invalid CIDR aborts startup. Set this + to the address pixa sees for requests that come through your proxy, such as + `172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else + (see the login limit under Routes). For a proxy on the Docker host that + connects to pixa over `127.0.0.1`, that address is the gateway of the + container's Docker network (`172.17.0.1` on the default bridge), not the + proxy's own address; a proxy that connects through another of the host's + addresses is seen with that address. To be sure which address it is, set this + to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the + proxy, and read `remoteIP` in pixa's request log line for it +- `upstream_fetch_timeout` — time allowed for one fetch from an upstream 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 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 @@ -521,11 +511,11 @@ Key settings in more detail: 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 `_pragma=journal_mode(WAL)` in one you set -- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the - disk cache entirely; omitted defaults to 75% of the sum of the free space on - the filesystem containing `/cache/` and the bytes of source and - transformed images the cache already holds, worked out at startup (minimum - 500 MiB) +- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk + cache entirely; omitted defaults to 75% of the sum of the free space on the + filesystem containing `/cache/` and the bytes of source and + transformed images the cache already holds, worked out at startup (minimum 500 + MiB) - `upstream_connections` — the most connections to upstream hosts at once, all 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 @@ -540,10 +530,10 @@ Key settings in more detail: - `maintenance_mode` — while `true`, the image routes (`/v1/image/` and `/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`) - still answers 200 and reports `"maintenance_mode": true`. It stays 200 - because the image's Docker `HEALTHCHECK` requests it: a 503 there would make - the container unhealthy, and upaas marks a deploy failed when its container - is unhealthy. The login and URL generator pages and `/metrics` keep working + still answers 200 and reports `"maintenance_mode": true`. It stays 200 because + the image's Docker `HEALTHCHECK` requests it: a 503 there would make the + container unhealthy, and upaas marks a deploy failed when its container is + unhealthy. The login and URL generator pages and `/metrics` keep working See `configs/config.example.yml` for all options with defaults. @@ -565,21 +555,23 @@ See `configs/config.example.yml` for all options with defaults. This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard: normalized scripts in `script/` are the entrypoints for the -development workflow, and the Makefile targets are thin shims that call -them. We provide: +development workflow, and the Makefile targets are thin shims that call them. We +provide: -- `script/bootstrap` — install git, make and Go and download the Go modules - (idempotent); with `--cgo`, also the C compiler and the libvips and libheif - libraries that compiling pixa needs -- `script/setup` — make a fresh clone ready for development - (bootstrap, then install-precommit) +- `script/bootstrap` — install git, make, Go, Node, Yarn and prettier and + download the Go modules (idempotent); with `--cgo`, the C compiler and the + libvips and libheif libraries that compiling pixa needs instead of Node, Yarn + and prettier +- `script/setup` — make a fresh clone ready for development (bootstrap, then + install-precommit) - `script/projectname` — output the project name ("pixa") - `script/test` — run the test suite: build the `test` phase of the `Dockerfile`, tagged `pixa-test` - `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`, tagged `pixa-lint`; the linter never runs on the host -- `script/fmt` — format all code (writes) -- `script/fmt-check` — check formatting (read-only), on the host +- `script/fmt` — format the Go code with gofmt and the markdown with prettier + (writes) +- `script/fmt-check` — check the same formatting (read-only), on the host - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname`, with the version from `git describe`; the image's build stage depends on the `lint` @@ -591,14 +583,18 @@ them. We provide: then `script/check`, then build the image as `script/docker` does - `script/precommit` — pre-commit checks (`go mod tidy` guard, then `script/check`) -- `script/install-precommit` — install the git pre-commit hook that - runs `script/precommit` +- `script/install-precommit` — install the git pre-commit hook that 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. +`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 diff --git a/TODO.md b/TODO.md index ffff926..cabe9e2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,28 +1,27 @@ # Workflow -* branch per issue from `next` -* do the work in Next Step -* move Next Step to the top of Completed Steps -* `TODO.md` merges with git's union merge (`.gitattributes`), which never +- branch per issue from `next` +- do the work in Next Step +- move Next Step to the top of Completed Steps +- `TODO.md` merges with git's union merge (`.gitattributes`), which never reports a conflict: read the merged entries after every merge or rebase -* move the top item of Future Steps into Next Step -* commit (`TODO.md` changes in the same commit as the work) -* open a PR based on `next` -* an independent reviewer who did not write the change gates it -* 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` into `main`, via the single milestone PR -* push +- move the top item of Future Steps into Next Step +- commit (`TODO.md` changes in the same commit as the work) +- open a PR based on `next` +- an independent reviewer who did not write the change gates it +- 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` into `main`, via the single milestone PR +- push # Status -pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work -lands on `next`, and `main` receives only the milestone PR that the -owner merges. `next` is at the canonical `golangci-lint` v2.12.2 config -and is green. Recent work extracted the internal/magic, -internal/allowlist, internal/httpfetcher, and internal/signature -packages. The gosec findings from the 2026-07-06 survey are resolved. -The disk cache is now size-bounded with LRU eviction +pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on +`next`, and `main` receives only the milestone PR that the owner merges. `next` +is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work +extracted the internal/magic, internal/allowlist, internal/httpfetcher, and +internal/signature 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. # Next Step @@ -31,6 +30,16 @@ P2: security: per-IP rate limiting on the image routes # Completed Steps +- 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`, @@ -103,13 +112,12 @@ P2: security: per-IP rate limiting on the image routes (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/205 (`.dockerignore` patterns at every - depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin - `cmd/pixad/main.go`) and https://git.eeqj.de/sneak/pixa/issues/208 - (`fetch-depth: 0` on the CI checkout, so the build sees the tags). Its rule - that no build stage runs `git describe` is not followed: pixa takes the - version from the `.git` in the build context, per - https://git.eeqj.de/sneak/pixa/issues/166, as the copy on `sneak/prompts` - `next` already says. + depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin `cmd/pixad/main.go`) + and https://git.eeqj.de/sneak/pixa/issues/208 (`fetch-depth: 0` on the CI + checkout, so the build sees the tags). Its rule that no build stage runs + `git describe` is not followed: pixa takes the version from the `.git` in the + build context, per 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): `TestImageProxyFlow` in `internal/server` starts the database, handlers and middleware from the constructors `pixad` uses, with a fresh state directory, @@ -125,13 +133,12 @@ P2: security: per-IP rate limiting on the image routes `httpfetcher.Config.DialContext` connects in place of the dialer that refuses internal addresses, the URL and redirect checks still running, and `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 (closes #199): a new test in `internal/handlers` makes a URL on the - generator page with a `ttl` of one second, checks that `/v1/e/` serves it at - once, waits two seconds and checks that it then answers 410. The test waits - for real, as pixa reads the clock directly when it makes and checks a URL; it - waits two seconds because the time a URL expires is kept in whole seconds. - Test only. +- 2026-10-04 a URL made on the generator page with a `ttl` is tested to expire + (closes #199): a new test in `internal/handlers` makes a URL on the generator + page with a `ttl` of one second, checks that `/v1/e/` serves it at once, waits + two seconds and checks that it then answers 410. The test waits for real, as + pixa reads the clock directly when it makes and checks a URL; it 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` (`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for `allowlist_hosts` with the same matcher; an entry of either list that is @@ -149,30 +156,29 @@ P2: security: per-IP rate limiting on the image routes `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` 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, and a URL made on the generator page with a `ttl` answering 410 once - the `ttl` has passed (https://git.eeqj.de/sneak/pixa/issues/199); - `CONVENTIONS.md` is deleted, as `REPO_POLICIES.md` links the canonical Go HTTP - server conventions. + cover every check it made except two: fetching a real image from the internet, + and a URL made on the generator page with a `ttl` answering 410 once the `ttl` + has passed (https://git.eeqj.de/sneak/pixa/issues/199); `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 #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 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 parameter the driver reads, so the database was never in WAL mode. -- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` - only passes through a periodic pass (closes #189): it slept for three - eviction intervals before writing its file, and a startup pass still running - then could adopt the file itself. It now holds the test database's only - connection until the startup pass waits for it after walking the empty - variant directory, writes the file and lets the connection go, as +- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` only + passes through a periodic pass (closes #189): it slept for three eviction + intervals before writing its file, and a startup pass still running then could + adopt the file itself. It now holds the test database's only connection until + the startup pass waits for it after walking the empty variant directory, + writes the file and lets the connection go, as `TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation pass can adopt the file. Test only. - 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, check that `GET /` without a login session shows the login form; a - wrong key shows it again with an error and sets no session cookie; the right - key answers 303 to `/` with a session cookie marked `Secure`, `HttpOnly` and + handler tests (closes #77): new tests in `internal/handlers`, with no network, + check that `GET /` without a login session shows the login form; a wrong key + shows it again with an error and sets no session cookie; the right key answers + 303 to `/` with a session cookie marked `Secure`, `HttpOnly` and `SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout` 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 @@ -182,11 +188,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 `.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 - conflict and keep both entries. Git now never reports a conflict in - `TODO.md`: a real one keeps both versions of the lines, and two entries that - share an identical line can end up one inside the other, which a rebase can - do to an entry already on `next`. The Workflow above says to read the merged - entries after every merge or rebase. + conflict and keep both entries. Git now never reports a conflict in `TODO.md`: + a real one keeps both versions of the lines, and two entries that share an + identical line can end up one inside the other, which a rebase can do to an + entry already on `next`. The Workflow above says to read the merged entries + after every merge or rebase. - 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 opens, after the database is open, as 75% of the sum of the free space on the @@ -210,10 +216,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 not parse already did. - 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 warning; its successor `gomodguard_v2` runs with the shared - module block list, and `depguard` keeps `net/http/httptest` out of files that - are not tests. The tree needed no code changes. + the deprecated `gomodguard` is switched off, so lint runs print no deprecation + warning; its successor `gomodguard_v2` runs with the shared module block list, + and `depguard` keeps `net/http/httptest` out of files that are not tests. The + tree needed no code changes. - 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 page's two inline `onclick` handlers moved into @@ -232,30 +238,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 codes, and what running outside Docker needs; `configs/Caddyfile` is the example, checked with `caddy validate`. -- 2026-10-04 the metrics basic auth, CORS preflight, request logging and - metrics recording have tests (closes #79): `MetricsAuth` on its own answers - 401 with a challenge without credentials or with a wrong username or password - and lets the configured ones through; a preflight request gets `*` for any - origin when `access_control_allow_origin` is `*` and no - `Access-Control-Allow-Origin` from another origin than the configured one; a - `POST /` carrying the signing key leaves no trace of it in the request log - line, and the login handler's own log lines leave out the submitted key; the - metrics middleware on its own records a request it served, and the router - records nothing while no metrics username is set. Not tested: that the router - puts the basic auth in front of `/metrics` and records requests when a - metrics username is set. Only one test per package can set up `/metrics`, and - in `internal/server` that is `TestMaintenanceModeKeepsOtherRoutes`, which - needs the owner's approval to change; #180 holds it. Tests only; the basic - auth library already compares the password in constant time. +- 2026-10-04 the metrics basic auth, CORS preflight, request logging and metrics + recording have tests (closes #79): `MetricsAuth` on its own answers 401 with a + challenge without credentials or with a wrong username or password and lets + the configured ones through; a preflight request gets `*` for any origin when + `access_control_allow_origin` is `*` and no `Access-Control-Allow-Origin` from + another origin than the configured one; a `POST /` carrying the signing key + leaves no trace of it in the request log line, and the login handler's own log + lines leave out the submitted key; the metrics middleware on its own records a + request it served, and the router records nothing while no metrics username is + set. Not tested: that the router puts the basic auth in front of `/metrics` + and records requests when a metrics username is set. Only one test per package + can set up `/metrics`, and in `internal/server` that is + `TestMaintenanceModeKeepsOtherRoutes`, which needs the owner's approval to + change; #180 holds it. Tests only; the basic auth library already compares the + password in constant time. - 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 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 parent domain, a sibling host, a subdomain or the host with another - domain appended (401), an unparseable path (400), `localhost` as the - upstream host (403) and an upstream error (502); that an allowlisted host is - served without a signature, another host only with a valid one; and the - answers of `/robots.txt` and the health check. No code changes. + expired signature on a host not on the allowlist, or a valid one sent for its + parent domain, a sibling host, a subdomain or the host with another domain + appended (401), an unparseable path (400), `localhost` as the upstream host + (403) and an upstream error (502); that an allowlisted host is served without + a signature, another host only with a valid one; and the answers of + `/robots.txt` and the health check. No code changes. - 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 request an ID, its own `X-Request-ID` when that is at most 64 letters, digits, @@ -272,26 +278,26 @@ P2: security: per-IP rate limiting on the image routes `Vary: Accept` is left to #88. - 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 - needs and the status codes it answers with, and says `q` and `fit` are part - of what is cached; "Encrypted URLs" covers logging in, making one on the + needs and the status codes it answers with, and says `q` and `fit` are part 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; "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; `scripts/manual-test.sh` is left to #97. - 2026-10-04 shutdown stops cache eviction in progress (closes #102): `StartEviction` runs the eviction goroutine with its own context, which - `StopEviction` cancels, so a pass in progress stops at its next database - call, file, row or eviction candidate instead of running to completion, and - no pass starts after it, so a stop logs at most one warning; - `StopEviction` takes a context and, when that context ends before the - goroutine exits, stops waiting and returns its error; the handlers' stop hook - passes fx's stop context, so an eviction still running when fx's stop - deadline ends fails the stop and makes the exit code 1. + `StopEviction` cancels, so a pass in progress stops at its next database call, + file, row or eviction candidate instead of running to completion, and no pass + starts after it, so a stop logs at most one warning; `StopEviction` takes a + context and, when that context ends before the goroutine exits, stops waiting + and returns its error; the handlers' stop hook passes fx's stop context, so an + eviction still running when fx's stop deadline ends fails the stop and makes + the exit code 1. - 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 - the `ImageCache` interface or `Service`; the `SignatureValidator`, - `Allowlist` and `Storage` interfaces, which nothing implemented or used, are - deleted. Nothing else changes. + the `ImageCache` interface or `Service`; the `SignatureValidator`, `Allowlist` + and `Storage` interfaces, which nothing implemented or used, are deleted. + Nothing else changes. - 2026-10-04 upstream host semaphores and variant `.meta` files no longer 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 @@ -303,21 +309,20 @@ P2: security: per-IP rate limiting on the image routes cache directories pixa uses (`cache/sources`, `cache/metadata`, `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 - list the same output formats, `jpg` and `original` included; the TLS - sentence names `allow_http` as its exception; "Metrics" says only generic - HTTP and Go runtime metrics exist, measured and served only when the metrics - username and password are set. -- 2026-10-03 shutdown sets the exit code and waits for image processing - (closes #86): fx alone handles SIGINT and SIGTERM, and the server's own - signal handler is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's - code: 0 for a signal, 1 when the HTTP server cannot listen or the app fails - to start or to stop; the server's stop hook, which fx waits for, stops the - HTTP server, waits for the images still being processed, both within 5 - seconds, then flushes Sentry; images still being processed after that are - logged with their count and make the exit code 1; a Sentry DSN that cannot be - used fails startup, so the stop hooks of what had already started run, - instead of exiting the process from a goroutine; the eviction loop is left to - #102. + list the same output formats, `jpg` and `original` included; the TLS sentence + names `allow_http` as its exception; "Metrics" says only generic HTTP and Go + runtime metrics exist, measured and served only when the metrics username and + password are set. +- 2026-10-03 shutdown sets the exit code and waits for image processing (closes + #86): fx alone handles SIGINT and SIGTERM, and the server's own signal handler + is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's code: 0 for a + signal, 1 when the HTTP server cannot listen or the app fails to start or to + stop; the server's stop hook, which fx waits for, stops the HTTP server, waits + for the images still being processed, both within 5 seconds, then flushes + Sentry; images still being processed after that are logged with their count + and make the exit code 1; a Sentry DSN that cannot be used fails startup, so + the stop hooks of what had already started run, 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 (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, @@ -332,11 +337,11 @@ P2: security: per-IP rate limiting on the image routes upstream fetch or cached source read and one transcode through `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 - error holding no upstream connection or processing slot, and stop waiting - when their own context ends; the request doing the processing waits for it - even then, up to its deadline; a request whose context has already ended - starts nothing; each request counts one miss, and the processing counts its - fetch and transcode once; a panic while processing is reported to Sentry when + error holding no upstream connection or processing slot, and stop waiting when + their own context ends; the request doing the processing waits for it even + then, up to its deadline; a request whose context has already ended starts + nothing; each request counts one miss, and the processing counts its 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 stopping pixad; documented in `README.md`. - 2026-09-29 only the image routes send CORS headers (closes #98): the CORS @@ -345,20 +350,20 @@ P2: security: per-IP rate limiting on the image routes still answers a preflight `OPTIONS` request; the login and URL generator pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`; documented in `README.md` and `config.example.yml`. -- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not - `dev` (closes #166): `.dockerignore` lets `.git` into the build context, - without `.git/config`; with no `VERSION` build argument the `Dockerfile` - takes the version from `git describe --tags --always`, and fails the build if - the context carries `.git` and no version comes out; `ARG VERSION` has no - default; pixad logs its version, with its name and architecture, as its first - log line at startup. -- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes - #159): `deploy/docker-entrypoint.sh` creates the directory if it is missing, - gives the directory and everything in it to `pixad` when the directory or one - of its top-level entries belongs to another user or group, sets its mode to - `750`, then runs the server as `pixad`; data left by an earlier run under - another uid is taken over this way; "Running under upaas" in `README.md` no - longer tells the operator to create or chown the host directory. +- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not `dev` + (closes #166): `.dockerignore` lets `.git` into the build context, without + `.git/config`; with no `VERSION` build argument the `Dockerfile` takes the + version from `git describe --tags --always`, and fails the build if the + context carries `.git` and no version comes out; `ARG VERSION` has no default; + pixad logs its version, with its name and architecture, as its first log line + at startup. +- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes #159): + `deploy/docker-entrypoint.sh` creates the directory if it is missing, gives + the directory and everything in it to `pixad` when the directory or one of its + top-level entries belongs to another user or group, sets its mode to `750`, + then runs the server as `pixad`; data left by an earlier run under another uid + is taken over this way; "Running under upaas" in `README.md` no longer tells + the operator to create or chown the host directory. - 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 (`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by @@ -397,8 +402,8 @@ P2: security: per-IP rate limiting on the image routes `ARG VERSION` sits just above the build, so a new version reruns neither `script/bootstrap` nor the tests. - 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the - migration files moved, contents unchanged, from `internal/database/schema/` - to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the + migration files moved, contents unchanged, from `internal/database/schema/` to + `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the `internal/db/migrations` package embeds them and `internal/database` reads them through its `FS()`; the `internal/database` package itself stays; the version still comes from the filename prefix, so a database that has recorded @@ -413,8 +418,8 @@ P2: security: per-IP rate limiting on the image routes `=` 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 `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 not owned on the host by a person's login account; the first-run step of + the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad` 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. - 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 @@ -423,29 +428,27 @@ P2: security: per-IP rate limiting on the image routes 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; documented in `README.md`. -- 2026-09-28 add the four settings `README.md` documented but pixa did not - have, which aborted startup as unknown keys (closes #61): +- 2026-09-28 add the four settings `README.md` documented but pixa did not have, + which aborted startup as unknown keys (closes #61): `access_control_allow_origin` (default `*`, the CORS origin), `upstream_fetch_timeout` (default `30s`), `upstream_max_response_size` - (default 50 MiB) and `downstream_timeout` (default `60s`, both the - server's write timeout and the per-request timeout); each has a - `PIXA_` variable; durations are positive Go duration strings, the size a - whole number of bytes up to 1 GiB, the origin `*` or one `http` or - `https` origin as `README.md` describes it; an invalid value - aborts startup naming the key and the value; documented in - `config.example.yml` and `README.md`. -- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` - counts the cached source images and processed variants (`source_content` - plus `variant_content`) and takes their size from `Cache.UsageBytes`, - instead of reading `request_cache` and `output_content`, which nothing - writes; those two tables are left in the schema; a disabled disk cache - reports no items and no size. A hit is counted even when the request - context has ended. A miss is counted after it is served or fails, also - when the request context has ended by then, with the bytes it read from - upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move, - 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. + (default 50 MiB) and `downstream_timeout` (default `60s`, both the server's + write timeout and the per-request timeout); each has a `PIXA_` variable; + durations are positive Go duration strings, the size a whole number of bytes + up to 1 GiB, the origin `*` or one `http` or `https` origin as `README.md` + describes it; an invalid value aborts startup naming the key and the value; + documented in `config.example.yml` and `README.md`. +- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` counts + the cached source images and processed variants (`source_content` plus + `variant_content`) and takes their size from `Cache.UsageBytes`, instead of + reading `request_cache` and `output_content`, which nothing writes; those two + tables are left in the schema; a disabled disk cache reports no items and no + size. A hit is counted even when the request context has ended. A miss is + counted after it is served or fails, also when the request context has ended + by then, with the bytes it read from upstream, so `upstream_fetch_count` and + `upstream_fetch_bytes` move, 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 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 @@ -456,220 +459,201 @@ P2: security: per-IP rate limiting on the image routes 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 `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; the limit is a `RateLimit` middleware in `internal/middleware` on + counted by its /64, and an IPv4-mapped address as the IPv4 address it carries; + the limit is a `RateLimit` middleware in `internal/middleware` on `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 `README.md`. -- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed - cache errors (closes #72): an `exp` in the URL that is not a whole - number, an empty `exp=` included, is a 400 naming `exp` and the value, - instead of being ignored and answered with 401 as if the URL had no - `exp`; only an `exp` missing from the URL is unchanged; `README.md` says - so where it documents `exp`. A failed variant `.meta` write, source - metadata JSON write, `Stats` count query, stats counter update, negative - cache write or expired negative cache delete is now logged at `warn` - with the path or key and the error, and stays non-fatal. -- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a - `fit` in the URL with an empty value (`fit=`) is a 400 naming `fit`, - instead of being served as `cover` and verified against a signature - made for `cover`; only a `fit` missing from the URL is still `cover`; - any other value still goes through the existing fit-mode check; - `README.md` says so where it documents `fit`. -- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q` - that is not a whole number from 1 to 100, an empty `q` included, is a - 400 naming `q` and the value, instead of being served at the default - 85; the route reads `q` with the generator's quality check - (`parseFormInt` with `minQuality` and `maxQuality`); only a `q` missing - from the URL is still 85; a query string that cannot be decoded, such - as `q=80%`, is a 400 showing it; any query parameter given more than - once (`q`, `fit`, `sig`, `exp` alike) is a 400 naming it, so none is - read from its first value only; `README.md` states the range and both - query-string rules. -- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes - #133): a variable whose name starts with `PIXA_` but is neither a - setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as - an unknown config key does, and `PIXA_PORT` is named with a pointer to - `PORT`; the check runs after the config file loads, so the variables - the file's `env:` section sets are checked too; documented in - `README.md`. -- 2026-09-28 start on a fresh upaas volume (closes #129): the image - starts as root only to give `/var/lib/pixa` to `pixad` when `pixad` - does not own it (`deploy/docker-entrypoint.sh`), then runs the server - as `pixad` through `su-exec`, so a root-owned host directory - bind-mounted there no longer stops the container at startup; - `README.md` gains a "Running under upaas" section. -- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + - `script/lint` (closes #104): `make lint` calls `script/lint`, the only - way the linter is run; inside a container (both Dockerfiles set - `container=docker`) it runs `golangci-lint`, anywhere else it builds the - hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again; - the `Dockerfile` lint stage runs `make lint`; no host or nix-shell - `golangci-lint` path remains (`script/bootstrap` installs no linter); - a per-run `CACHEBUST` build-arg keeps the lint step from being served - from cache, and a tmpfs mount on that step keeps Go's and - golangci-lint's caches out of its layer, so a run leaves no large build - cache behind; `golangci-lint config verify` stays out, as it fetches its - schema over an unpinned live HTTPS call -- 2026-09-28 every setting as an environment variable (closes #128, also - covers #99): each config key can be set by `PIXA_` plus the key in upper - case (`.` written as `_`), and the port by `PORT`; a variable present in - the environment, even empty, wins over the config file, which wins over - the default; the typed getters read the variable first, so every existing - check applies to it and a bad value aborts startup naming the variable; - lists are comma-separated, and an empty variable (or `""` in the file) is - an empty list; the Docker image no longer bakes in `config.docker.yml` or - passes `--config`, and its `HEALTHCHECK` probes `PORT` (default `8080`); - the config file is looked for under `/etc/pixa` and `~/.config/pixa` - instead of the daemon name `pixad`; documented in `README.md` and - `config.example.yml`. -- 2026-09-28 quality and fit in the URL signature (closes #60): the signed - data is now `host:path:query:width:height:format:expiration:quality:fit`, - using `85` and `cover` when the URL has no `q` or `fit`, so one signed - URL can no longer be replayed across other quality and fit values to - create unauthorized cache entries and transcodes; the known-answer - vectors in `internal/signature/golden_test.go` and the README signature - 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-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed cache + errors (closes #72): an `exp` in the URL that is not a whole number, an empty + `exp=` included, is a 400 naming `exp` and the value, instead of being ignored + and answered with 401 as if the URL had no `exp`; only an `exp` missing from + the URL is unchanged; `README.md` says so where it documents `exp`. A failed + variant `.meta` write, source metadata JSON write, `Stats` count query, stats + counter update, negative cache write or expired negative cache delete is now + logged at `warn` with the path or key and the error, and stays non-fatal. +- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a `fit` in the + URL with an empty value (`fit=`) is a 400 naming `fit`, instead of being + served as `cover` and verified against a signature made for `cover`; only a + `fit` missing from the URL is still `cover`; any other value still goes + through the existing fit-mode check; `README.md` says so where it documents + `fit`. +- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q` that is + not a whole number from 1 to 100, an empty `q` included, is a 400 naming `q` + and the value, instead of being served at the default 85; the route reads `q` + with the generator's quality check (`parseFormInt` with `minQuality` and + `maxQuality`); only a `q` missing from the URL is still 85; a query string + that cannot be decoded, such as `q=80%`, is a 400 showing it; any query + parameter given more than once (`q`, `fit`, `sig`, `exp` alike) is a 400 + naming it, so none is read from its first value only; `README.md` states the + range and both query-string rules. +- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes #133): + a variable whose name starts with `PIXA_` but is neither a setting's variable + nor `PIXA_CONFIG_PATH` aborts startup naming it, as an unknown config key + does, and `PIXA_PORT` is named with a pointer to `PORT`; the check runs after + the config file loads, so the variables the file's `env:` section sets are + checked too; documented in `README.md`. +- 2026-09-28 start on a fresh upaas volume (closes #129): the image starts as + root only to give `/var/lib/pixa` to `pixad` when `pixad` does not own it + (`deploy/docker-entrypoint.sh`), then runs the server as `pixad` through + `su-exec`, so a root-owned host directory bind-mounted there no longer stops + the container at startup; `README.md` gains a "Running under upaas" section. +- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + `script/lint` + (closes #104): `make lint` calls `script/lint`, the only way the linter is + run; inside a container (both Dockerfiles set `container=docker`) it runs + `golangci-lint`, anywhere else it builds the hash-pinned `Dockerfile.lint`, + whose last step runs `script/lint` again; the `Dockerfile` lint stage runs + `make lint`; no host or nix-shell `golangci-lint` path remains + (`script/bootstrap` installs no linter); a per-run `CACHEBUST` build-arg keeps + the lint step from being served from cache, and a tmpfs mount on that step + keeps Go's and golangci-lint's caches out of its layer, so a run leaves no + large build cache behind; `golangci-lint config verify` stays out, as it + fetches its schema over an unpinned live HTTPS call +- 2026-09-28 every setting as an environment variable (closes #128, also covers + #99): each config key can be set by `PIXA_` plus the key in upper case (`.` + written as `_`), and the port by `PORT`; a variable present in the + environment, even empty, wins over the config file, which wins over the + default; the typed getters read the variable first, so every existing check + applies to it and a bad value aborts startup naming the variable; lists are + comma-separated, and an empty variable (or `""` in the file) is an empty list; + the Docker image no longer bakes in `config.docker.yml` or passes `--config`, + and its `HEALTHCHECK` probes `PORT` (default `8080`); the config file is + looked for under `/etc/pixa` and `~/.config/pixa` instead of the daemon name + `pixad`; documented in `README.md` and `config.example.yml`. +- 2026-09-28 quality and fit in the URL signature (closes #60): the signed data + is now `host:path:query:width:height:format:expiration:quality:fit`, using + `85` and `cover` when the URL has no `q` or `fit`, so one signed URL can no + longer be replayed across other quality and fit values to create unauthorized + cache entries and transcodes; the known-answer vectors in + `internal/signature/golden_test.go` and the README signature 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 `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 naming the key and value; an omitted key defaults to the RFC 1918 - private ranges, an explicitly empty list trusts no one, and an explicit - list replaces the default); a new - `internal/clientip` package resolves the client address by honoring - `X-Forwarded-For` only when the direct peer is a trusted proxy, walking - the chain right-to-left to the rightmost non-proxy entry, so a client - connecting directly cannot spoof its address; the resolved address is - stored in the request context by a new middleware and used by the - request-logging middleware and the login-attempt logs in place of the - raw peer address; documented in `README.md` and `config.example.yml`. + `net/netip` list parser as `blocked_networks` (an invalid entry aborts startup + naming the key and value; an omitted key defaults to the RFC 1918 private + ranges, an explicitly empty list trusts no one, and an explicit list replaces + the default); a new `internal/clientip` package resolves the client address by + honoring `X-Forwarded-For` only when the direct peer is a trusted proxy, + walking the chain right-to-left to the rightmost non-proxy entry, so a client + connecting directly cannot spoof its address; the resolved address is stored + in the request context by a new middleware and used by the 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 - `blocked_networks` config key taking a list of CIDRs (parsed with - `net/netip`, an invalid entry aborts startup naming the key and value), - added to the built-in blocklist rather than replacing it; the built-in - ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments - `192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96` - (IPv4-mapped forms covered); enforcement stays in the dial-time - re-resolution so the DNS-rebinding window remains closed; documented in - `README.md` and `config.example.yml`. -- 2026-09-21 validate dimensions and fit mode on the encrypted-URL - route and the token generator (closes #62): `imgcache.ValidateDimension` - alone holds the `MaxDimension` bound and is used by the path parser, by - the new `ValidateImageRequest` (which also applies `ValidateFitMode`) - and by the generator; both the `/v1/image/` and `/v1/e/` routes call - `ValidateImageRequest`, so an over-limit size or an unknown fit mode is a - 400 rather than an out-of-memory or a 500 from the processor; the URL - generator answers 400 naming the field for a `width` or `height` that is - not a number or fails the shared check, a `quality` that is not a number - from 1 to 100, a `ttl` that is not a number from 0 to the largest number - of seconds the expiry calculation can hold, or an unknown `fit`; an empty - `quality` is 85 and - an empty `ttl` never expires; the form's width and height inputs stop at - 8192 -- 2026-09-21 http.Server hardening (closes #92): added - `HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and - `HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the - existing timeouts and wired them onto the server; added a `LimitBody` - middleware capping the two form POST bodies (`POST /`, `POST /generate`) - at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF - middleware so an oversized body is refused as 413 rather than being read - as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged -- 2026-08-07 update golangci-lint to v2.12.2 with the canonical - `.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`, + `blocked_networks` config key taking a list of CIDRs (parsed with `net/netip`, + an invalid entry aborts startup naming the key and value), added to the + built-in blocklist rather than replacing it; the built-in ranges extended to + CGNAT `100.64.0.0/10`, IETF protocol assignments `192.0.0.0/24`, benchmark + `198.18.0.0/15`, and NAT64 `64:ff9b::/96` (IPv4-mapped forms covered); + enforcement stays in the dial-time re-resolution so the DNS-rebinding window + remains closed; documented in `README.md` and `config.example.yml`. +- 2026-09-21 validate dimensions and fit mode on the encrypted-URL route and the + token generator (closes #62): `imgcache.ValidateDimension` alone holds the + `MaxDimension` bound and is used by the path parser, by the new + `ValidateImageRequest` (which also applies `ValidateFitMode`) and by the + generator; both the `/v1/image/` and `/v1/e/` routes call + `ValidateImageRequest`, so an over-limit size or an unknown fit mode is a 400 + rather than an out-of-memory or a 500 from the processor; the URL generator + answers 400 naming the field for a `width` or `height` that is not a number or + fails the shared check, a `quality` that is not a number from 1 to 100, a + `ttl` that is not a number from 0 to the largest number of seconds the expiry + calculation can hold, or an unknown `fit`; an empty `quality` is 85 and an + empty `ttl` never expires; the form's width and height inputs stop at 8192 +- 2026-09-21 http.Server hardening (closes #92): added `HTTPReadHeaderTimeout` + (10s, bounds the slowloris header dribble) and `HTTPIdleTimeout` (120s, bounds + keep-alive reuse) alongside the existing timeouts and wired them onto the + server; added a `LimitBody` middleware capping the two form POST bodies + (`POST /`, `POST /generate`) at `MaxFormBytes` (1 MiB) and returning 413, + applied ahead of the CSRF middleware so an oversized body is refused as 413 + rather than being read as a missing CSRF token (403); left `WriteTimeout` at + 60s unchanged +- 2026-08-07 update golangci-lint to v2.12.2 with the canonical `.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` — - white-box test files renamed to `*_internal_test.go`), including #55's - code absorbed after it merged, iterating the pinned linter to - `0 issues.`; no single finding total is substantiable, since - golangci-lint's `uniq-by-line` reveals new findings on a line as - others there are fixed — the documented re-measurements were 81 after - the #53 merge and 149 after the #55 merge; three behavior changes, so - not a pure no-op: `Cache.StoreVariant` now takes a `context.Context` - (`noctx`), so a cancelled request skips its best-effort accounting - row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and - leaked `.tmp-*.json` on failure, now fixed with explicit removals; and - the `signing_key` validation error text gained `value too short: `; - the eviction loop's uncancellable context is deferred to #102 under a - `//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep - the snake_case JSON wire/disk formats unchanged; `make check` green -- 2026-08-07 implement cache size management and eviction (closes - #51): new `cache_max_bytes` config key validated by the startup - framework (explicit values used exactly with no floor, `0` disables - the disk cache entirely, omitted defaults to max(75% of free space - on the filesystem containing `/cache/`, 500 MiB), logged - at startup); processed variants are now tracked in the database (a - new `variant_content` table and an LRU timestamp on `source_content`) - so total usage is two SUMs, never a directory scan on the hot path; a - background goroutine evicts globally least-recently-used entries - (variants and source blobs merged) to the limit, woken by a periodic - ticker and by write-pressure notifications from stores; a source - blob and ALL of its `source_metadata` references are deleted in one - transaction before the file is unlinked, so multi-referenced blobs - are never removed while referenced and rows never point at deleted - files; a startup and periodic reconciliation pass adopts untracked - variant files, drops rows for missing files, removes unreachable - source blobs, and sweeps stale temp files -- 2026-08-07 validate configuration on startup, fail fast on bad - config (closes #52): a config value that is set but unparseable or - invalid aborts startup naming the key and value (defaults apply only - to omitted keys), unknown config keys abort startup, a malformed - config file aborts instead of being skipped, and `state_dir` is - verified creatable and writable before the listener binds -- 2026-08-07 manual test pass of the auth and encrypted URL flows - against a locally built and running `pixad` (built from `main` at - `6573b9d`, port 18099, local throwaway config); all six checks - passed, plus all nine tests in `scripts/manual-test.sh` (closes #49): - - [x] visit `/` and see the login form: HTTP 200, `Pixa - Login` - page with `name="key"` password form - - [x] wrong key shows an error: POST `/` with `key=wrong-key` - returned HTTP 200 login page containing "Invalid signing key" - - [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 + white-box test files renamed to `*_internal_test.go`), including #55's code + absorbed after it merged, iterating the pinned linter to `0 issues.`; no + single finding total is substantiable, since golangci-lint's `uniq-by-line` + reveals new findings on a line as others there are fixed — the documented + re-measurements were 81 after the #53 merge and 149 after the #55 merge; three + behavior changes, so not a pure no-op: `Cache.StoreVariant` now takes a + `context.Context` (`noctx`), so a cancelled request skips its best-effort + accounting row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and + leaked `.tmp-*.json` on failure, now fixed with explicit removals; and the + `signing_key` validation error text gained `value too short: `; the eviction + loop's uncancellable context is deferred to #102 under a + `//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep the + snake_case JSON wire/disk formats unchanged; `make check` green +- 2026-08-07 implement cache size management and eviction (closes #51): new + `cache_max_bytes` config key validated by the startup framework (explicit + values used exactly with no floor, `0` disables the disk cache entirely, + omitted defaults to max(75% of free space on the filesystem containing + `/cache/`, 500 MiB), logged at startup); processed variants are now + tracked in the database (a new `variant_content` table and an LRU timestamp on + `source_content`) so total usage is two SUMs, never a directory scan on the + hot path; a background goroutine evicts globally least-recently-used entries + (variants and source blobs merged) to the limit, woken by a periodic ticker + and by write-pressure notifications from stores; a source blob and ALL of its + `source_metadata` references are deleted in one transaction before the file is + unlinked, so multi-referenced blobs are never removed while referenced and + rows never point at deleted files; a startup and periodic reconciliation pass + adopts untracked variant files, drops rows for missing files, removes + unreachable source blobs, and sweeps stale temp files +- 2026-08-07 validate configuration on startup, fail fast on bad config (closes + #52): a config value that is set but unparseable or invalid aborts startup + naming the key and value (defaults apply only to omitted keys), unknown config + keys abort startup, a malformed config file aborts instead of being skipped, + and `state_dir` is verified creatable and writable before the listener binds +- 2026-08-07 manual test pass of the auth and encrypted URL flows against a + locally built and running `pixad` (built from `main` at `6573b9d`, port 18099, + local throwaway config); all six checks passed, plus all nine tests in + `scripts/manual-test.sh` (closes #49): + - [x] visit `/` and see the login form: HTTP 200, `Pixa - Login` page with + `name="key"` password form + - [x] wrong key shows an error: POST `/` with `key=wrong-key` returned HTTP + 200 login page containing "Invalid signing key" + - [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` - (ttl=3600) produced a `/v1/e//img.jpeg` URL that returned - HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of - 61706 bytes - - [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched - after 3 s returned HTTP 410 Gone with - `{"error":"URL has expired","status":410,...}` - - [x] logout redirects back to login: GET `/logout` returned HTTP - 303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`; - subsequent GET `/` rendered the login form again -- 2026-08-07 fix the two remaining gosec findings (G124 in - internal/session): session cookies now always carry - Secure/HttpOnly/SameSite=Strict on both the set and clear paths; - `make check` green (closes #47) -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - Makefile shims, README Entrypoints section + (ttl=3600) produced a `/v1/e//img.jpeg` URL that returned HTTP + 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of 61706 + bytes + - [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched after 3 s + returned HTTP 410 Gone with + `{"error":"URL has expired","status":410,...}` + - [x] logout redirects back to login: GET `/logout` returned HTTP 303 to `/` + with `Set-Cookie: pixa_session=; Max-Age=0`; subsequent GET `/` + rendered the login form again +- 2026-08-07 fix the two remaining gosec findings (G124 in internal/session): + session cookies now always carry Secure/HttpOnly/SameSite=Strict on both the + set and clear paths; `make check` green (closes #47) +- 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-03-25 extract allowlist package from internal/imgcache (#41) - 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 bound imageprocessor.Process input read to prevent - unbounded memory use (#37); consolidate appname into an - internal/globals constant (#34) +- 2026-03-20 enforce and document exact-match-only signature verification (#40) +- 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-15 QA audit fixes for 1.0/MVP readiness (#25) -- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for - faster CI (#23) +- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for faster CI + (#23) - 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) -- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 - image processing items, now done) + golangci-lint and gosec fixes of that date (#14); arm64 Docker build fix (#16) +- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 image + processing items, now done) # Future Steps diff --git a/package.json b/package.json new file mode 100644 index 0000000..bab3bb1 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "license": "GPL-3.0", + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/script/bootstrap b/script/bootstrap index e826a28..31abd17 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,20 +3,33 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, -# make, or go). The linter is never installed on the host: golangci-lint -# runs only in the lint phase of the Dockerfile (see script/lint). +# make, or go). Node is used directly if installed; otherwise it is +# installed at a pinned version via nvm (installing nvm itself first, +# from a hash-verified release archive, never curl | sh). The linter is +# never installed on the host: golangci-lint runs only in the lint phase +# of the Dockerfile (see script/lint). # -# script/bootstrap git, make and Go, all the host needs: the -# checks compile pixa in Docker -# script/bootstrap --cgo also a C compiler and the CGO image -# libraries (pkg-config, vips, libheif) for -# the govips bindings, to compile pixa; the -# Dockerfile's test phase and build stage -# run this +# 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 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="" SUDO="" @@ -61,6 +74,69 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# verify_sha256 +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) ensure_cgo_deps() { # cgo compiles with gcc on Linux; build-base and build-essential @@ -98,9 +174,14 @@ main() { # Go toolchain if missing go; then pkg_install go golang go go; fi - # CGO image libraries, only where pixa is compiled + # CGO image libraries where pixa is compiled; elsewhere Node, Yarn + # and prettier if [ "$*" = "--cgo" ]; then ensure_cgo_deps + else + ensure_node + ensure_yarn + install_js_deps fi go mod download diff --git a/script/fmt b/script/fmt index 976c3a2..c7b584a 100755 --- a/script/fmt +++ b/script/fmt @@ -4,11 +4,31 @@ set -eu 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() { cd "$ROOT" echo "Formatting code..." # shellcheck disable=SC2046 # word splitting of file list is wanted gofmt -w $(find . -name '*.go' -not -path './vendor/*') + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 848fdc6..b8d3f00 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -5,6 +5,25 @@ set -eu 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() { cd "$ROOT" echo "Checking formatting..." @@ -13,6 +32,7 @@ main() { gofmt -l . | grep -v '^vendor/' exit 1 fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# 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==