access_control_allow_origin, upstream_fetch_timeout, upstream_max_response_size and downstream_timeout were in the README but unknown to pixa, so a config that followed it aborted startup. Each is now a setting with its PIXA_ variable, defaulting to the value that was fixed in the code: *, 30s, 50 MiB and 60s. Durations are positive Go duration strings; the size is a positive whole number of bytes; the origin is * or one scheme and host. An invalid value aborts startup naming the key and value. downstream_timeout replaces HTTPWriteTimeout for the server's write timeout and the per-request timeout. Model: opus-5-5
16 KiB
16 KiB
Workflow
- branch per issue from
next - do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (
TODO.mdchanges 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
nextonce review passes nextstays green and mergeable tomainat any time; only the owner mergesnextintomain, 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
(cache_max_bytes), closing the unbounded disk growth DoS vector.
Next Step
P1: rate limit global concurrent upstream fetches to prevent resource exhaustion
Completed Steps
- 2026-09-28 add the four settings
README.mddocumented but pixa did not have, which aborted startup as unknown keys (closes #61):access_control_allow_origin(default*, the CORS origin),upstream_fetch_timeout(default30s),upstream_max_response_size(default 50 MiB) anddownstream_timeout(default60s, both the server's write timeout and the per-request timeout); each has aPIXA_variable; durations are positive Go duration strings, sizes a whole number of bytes; an invalid value aborts startup naming the key and the value; documented inconfig.example.ymlandREADME.md. - 2026-09-28 refuse an unparseable
expon/v1/image/and log swallowed cache errors (closes #72): anexpin the URL that is not a whole number, an emptyexp=included, is a 400 namingexpand the value, instead of being ignored and answered with 401 as if the URL had noexp; only anexpmissing from the URL is unchanged;README.mdsays so where it documentsexp. A failed variant.metawrite, source metadata JSON write,Statscount query, stats counter update, negative cache write or expired negative cache delete is now logged atwarnwith the path or key and the error, and stays non-fatal. - 2026-09-28 refuse an empty
fiton/v1/image/(closes #139): afitin the URL with an empty value (fit=) is a 400 namingfit, instead of being served ascoverand verified against a signature made forcover; only afitmissing from the URL is stillcover; any other value still goes through the existing fit-mode check;README.mdsays so where it documentsfit. - 2026-09-28 refuse an invalid
qon/v1/image/(closes #134): aqthat is not a whole number from 1 to 100, an emptyqincluded, is a 400 namingqand the value, instead of being served at the default 85; the route readsqwith the generator's quality check (parseFormIntwithminQualityandmaxQuality); only aqmissing from the URL is still 85; a query string that cannot be decoded, such asq=80%, is a 400 showing it; any query parameter given more than once (q,fit,sig,expalike) is a 400 naming it, so none is read from its first value only;README.mdstates the range and both query-string rules. - 2026-09-28 unknown
PIXA_environment variables abort startup (closes #133): a variable whose name starts withPIXA_but is neither a setting's variable norPIXA_CONFIG_PATHaborts startup naming it, as an unknown config key does, andPIXA_PORTis named with a pointer toPORT; the check runs after the config file loads, so the variables the file'senv:section sets are checked too; documented inREADME.md. - 2026-09-28 start on a fresh upaas volume (closes #129): the image
starts as root only to give
/var/lib/pixatopixadwhenpixaddoes not own it (deploy/docker-entrypoint.sh), then runs the server aspixadthroughsu-exec, so a root-owned host directory bind-mounted there no longer stops the container at startup;README.mdgains a "Running under upaas" section. - 2026-09-28 run all linting in Docker via
Dockerfile.lint+script/lint(closes #104):make lintcallsscript/lint, the only way the linter is run; inside a container (both Dockerfiles setcontainer=docker) it runsgolangci-lint, anywhere else it builds the hash-pinnedDockerfile.lint, whose last step runsscript/lintagain; theDockerfilelint stage runsmake lint; no host or nix-shellgolangci-lintpath remains (script/bootstrapinstalls no linter); a per-runCACHEBUSTbuild-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 verifystays 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 byPORT; 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 inconfig.docker.ymlor passes--config, and itsHEALTHCHECKprobesPORT(default8080); the config file is looked for under/etc/pixaand~/.config/pixainstead of the daemon namepixad; documented inREADME.mdandconfig.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, using85andcoverwhen the URL has noqorfit, 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 ininternal/signature/golden_test.goand the README signature specification describe the new format. - 2026-09-28 Docker image healthcheck (closes #111): a
HEALTHCHECKin the runtime stage probing/.well-known/healthcheck.jsonwith busyboxwget;script/docker-smoke(make docker-smoke) builds the image, starts it with a throwawayPIXA_SIGNING_KEY, and passes only once Docker reports it healthy within 30 seconds, removing the container on exit; the Gitea workflow runs it afterscript/cibuild. - 2026-09-21 trusted-proxy client IP resolution (closes #94): a
trusted_proxiesconfig key taking a list of CIDRs, parsed by the samenet/netiplist parser asblocked_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 newinternal/clientippackage resolves the client address by honoringX-Forwarded-Foronly 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 inREADME.mdandconfig.example.yml. - 2026-09-21 blocked networks configuration extending SSRF protection: a
blocked_networksconfig key taking a list of CIDRs (parsed withnet/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 CGNAT100.64.0.0/10, IETF protocol assignments192.0.0.0/24, benchmark198.18.0.0/15, and NAT6464:ff9b::/96(IPv4-mapped forms covered); enforcement stays in the dial-time re-resolution so the DNS-rebinding window remains closed; documented inREADME.mdandconfig.example.yml. - 2026-09-21 validate dimensions and fit mode on the encrypted-URL
route and the token generator (closes #62):
imgcache.ValidateDimensionalone holds theMaxDimensionbound and is used by the path parser, by the newValidateImageRequest(which also appliesValidateFitMode) and by the generator; both the/v1/image/and/v1/e/routes callValidateImageRequest, 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 awidthorheightthat is not a number or fails the shared check, aqualitythat is not a number from 1 to 100, attlthat is not a number from 0 to the largest number of seconds the expiry calculation can hold, or an unknownfit; an emptyqualityis 85 and an emptyttlnever 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) andHTTPIdleTimeout(120s, bounds keep-alive reuse) alongside the existing timeouts and wired them onto the server; added aLimitBodymiddleware capping the two form POST bodies (POST /,POST /generate) atMaxFormBytes(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); leftWriteTimeoutat 60s unchanged - 2026-08-07 update golangci-lint to v2.12.2 with the canonical
.golangci.yml(v2 schema,default: allminus six disabled linters,lll88, tests included): bumped the pinnedgolangci/golangci-lint:v2.12.2-alpineimage inDockerfileand the release-archive sha256 pins inscript/bootstrap; fixed the findings the stricter config surfaced (notablyparalleltest,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 to0 issues.; no single finding total is substantiable, since golangci-lint'suniq-by-linereveals 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.StoreVariantnow takes acontext.Context(noctx), so a cancelled request skips its best-effort accounting row;MetadataStorage.Store's cleanup defer was dead onmainand leaked.tmp-*.jsonon failure, now fixed with explicit removals; and thesigning_keyvalidation error text gainedvalue too short:; the eviction loop's uncancellable context is deferred to #102 under a//nolint:contextcheck; three//nolint:tagliatelledirectives keep the snake_case JSON wire/disk formats unchanged;make checkgreen - 2026-08-07 implement cache size management and eviction (closes
#51): new
cache_max_bytesconfig key validated by the startup framework (explicit values used exactly with no floor,0disables the disk cache entirely, omitted defaults to max(75% of free space on the filesystem containing<state_dir>/cache/, 500 MiB), logged at startup); processed variants are now tracked in the database (a newvariant_contenttable and an LRU timestamp onsource_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 itssource_metadatareferences 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_diris 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 frommainat6573b9d, port 18099, local throwaway config); all six checks passed, plus all nine tests inscripts/manual-test.sh(closes #49):- visit
/and see the login form: HTTP 200,Pixa - Loginpage withname="key"password form - wrong key shows an error: POST
/withkey=wrong-keyreturned HTTP 200 login page containing "Invalid signing key" - correct signing key shows the generator form: POST
/returned HTTP 303 to/withSet-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict; GET/with that cookie renderedPixa - URL Generatorwith the/generateform and logout link - a generated encrypted URL serves the image: POST
/generate(ttl=3600) produced a/v1/e/<token>/img.jpegURL that returned HTTP 200,Content-Type: image/jpeg, an 800x600 baseline JPEG of 61706 bytes - 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,...} - logout redirects back to login: GET
/logoutreturned HTTP 303 to/withSet-Cookie: pixa_session=; Max-Age=0; subsequent GET/rendered the login form again
- visit
- 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 checkgreen (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-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-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)
Future Steps
- P1: strip EXIF and other metadata from processed images (privacy)
- P2: security
- referer blacklist
- per-IP rate limiting
- per-origin rate limiting
- P2: HTTP response handling
- Last-Modified headers
- Vary header for content negotiation
- X-Request-ID propagation
- P2: auto format selection (format=auto based on Accept header)
- P2: configuration
- YAML config file support
- P2: operational
- optional Sentry error reporting
- comprehensive request logging
- Prometheus performance metrics
- integration tests for the image proxy flow
- load tests to verify the 1k to 5k req/s target
- P2: documentation
- configuration options
- API endpoints
- deployment guide
- example nginx or caddy reverse proxy config