check / check (push) Failing after 3s
config.yaml and config.dev.yml are kept out of git because they can hold the signing key, but .dockerignore did not leave them out, so a local copy in the working tree reached the build context and, through COPY . ., a build-stage layer. .dockerignore now leaves them out in every directory and in any letter case. configs/config.example.yml is still sent. Model: opus-5-5
42 KiB
42 KiB
Workflow
- branch per issue from
next - do the work in Next Step
- move Next Step to the top of Completed Steps
TODO.mdmerges 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.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
P2: security: per-IP rate limiting on the image routes
Completed Steps
- 2026-10-04 local config files stay out of the Docker build context (closes
#211):
.dockerignorenow leaves outconfig.yamlandconfig.dev.ymlin every directory and in any letter case, the local config files.gitignorekeeps out of git because they can hold the signing key.configs/config.example.ymlis still sent.config.yml, which Getting Started creates, is in neither file: #212. - 2026-10-04
.gitignoreignores.claude/(closes #204): the entry and its comment are copied from the canonical.gitignoreinsneak/prompts, unanchored so it matches at every depth..dockerignorealready has.claude. - 2026-10-04
.dockerignorekeeps secrets out at every depth (closes #205): the file is now the standard one fromsneak/prompts, whose patterns match in every directory and, for environment files and private keys, in any letter case, so a nested.envorserver.keyno longer reaches the build context. pixa still sends.gitwithout.git/configin place of the standard file's.gitline, and still leaves out.gitignore,/binand/data. - 2026-10-04
REPO_POLICIES.mdmatches the canonical copy again (closes #196): it is replaced, unchanged, byprompts/REPO_POLICIES.mdfromsneak/promptsmain. The rules it adds that pixa's tree breaks are filed: #202 (lint and tests asDockerfilephases built with--no-cache), #203 (the workflow'sscript/docker-smokestep), #204 (.claude/in.gitignore), #205 (.dockerignorepatterns at every depth), #206 (a thincmd/pixad/main.go) and #208 (fetch-depth: 0on the CI checkout, so the build sees the tags). Its rule that no build stage runsgit describeis not followed: pixa takes the version from the.gitin the build context, per #166, as the copy onsneak/promptsnextalready says. - 2026-10-04 an integration test of the image proxy flow (closes #80):
TestImageProxyFlowininternal/serverstarts the database, handlers and middleware from the constructorspixaduses, with a fresh state directory, and replaces only the upstream origin with a local test server. For a resize with a change to JPEG and fororig, the first request goes through the router, the real fetcher, libvips, the disk cache and SQLite and answers 200 with the right content type and size andX-Pixa-Cache: MISS; the second answersHITwith the same image and the upstream has had one request; the source and the converted image are then incache/sourcesandcache/variants, with their rows insource_content,source_metadataandvariant_content. Two optional fields make this possible, whichpixaddoes not set and the config file and environment cannot:httpfetcher.Config.DialContextconnects in place of the dialer that refuses internal addresses, the URL and redirect checks still running, andhandlers.Params.Fetcherreplaces the fetcher the handlers build. - 2026-10-04 a URL made on the generator page with a
ttlis tested to expire (closes #199): a new test ininternal/handlersmakes a URL on the generator page with attlof 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 forallowlist_hostswith the same matcher; an entry of either list that is neither a host name (letters, digits, hyphens, underscores and dots, with at most one leading dot) nor an IP address, such as one with a port or a*.wildcard, aborts startup naming the setting and the entry. Both image routes refuse a request whoseReferernames a listed host with 403 and a JSON error before the signature, the cache and the upstream fetch, so it fetches nothing and is refused whether or not the image is cached. A request with noReferer, or one that does not parse as a URL with a host, is served, so the list is easily got around;README.mdandconfigs/config.example.ymlsay so. It does not apply to the login and generator pages. - 2026-10-04 fewer files in the repository root (closes #97):
config.example.ymlmoved unchanged toconfigs/config.example.yml, andREADME.md, the comments ininternal/config/config.goand the startup error for the placeholder signing key name the new path;scripts/manual-test.shand its directory are deleted, as the handler tests ininternal/handlerscover every check it made except two: fetching a real image from the internet, and a URL made on the generator page with attlanswering 410 once thettlhas passed (#199);CONVENTIONS.mdis deleted, asREPO_POLICIES.mdlinks 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 everydb_url, so a write that finds another in progress on another connection waits up to five seconds for it, and the defaultdb_urlturns on WAL mode with_pragma=journal_mode(WAL). The old default's_journal_mode=WALis not a parameter the driver reads, so the database was never in WAL mode. - 2026-10-04
TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartuponly 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, asTestEvictionRunsOnPeriodicScheduledoes, 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 ininternal/handlers, with no network, check thatGET /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 markedSecure,HttpOnlyandSameSite=Strict, with whichGET /shows the generator page;GET /logoutanswers 303 to/with an empty session cookie sent withMax-Age=0;POST /generatewithout a login session answers 303 to/;/v1/e/serves the image for a valid token, answers 410 for an expired one and 400 for one with a character changed, cut short or made with another signing key; and a URL made on the generator page is served by/v1/e/. No code changes. - 2026-10-04
TODO.mdmerges with git's union merge (closes #190): a root.gitattributes, copied fromsneak/prompts, marks itmerge=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 inTODO.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 onnext. The Workflow above says to read the merged entries after every merge or rebase. - 2026-10-04 the default
cache_max_bytesno 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 filesystem containing<state_dir>/cache/and what the cache already holds by its own size accounting, at least 500 MiB, so a cache filled to its limit keeps that limit across a restart. The computation and its tests moved frominternal/configtointernal/imgcache; the config only records whether the key was set. - 2026-10-04
TestEvictionRunsOnPeriodicScheduleno longer races the evictor (closes #183): it wrote each variant file and then inserted its accounting row by hand, and a reconciliation pass between the two adopted the file first, so the insert failed. It now writes the files only, while holding the test database's only connection so the evictor's startup pass waits after walking the empty variant directory; a periodic reconciliation pass then adopts the files and the eviction pass after it evicts them. No other test ininternal/imgcacheinserts a row by hand after starting the evictor. Test only. - 2026-10-04 a config file pixa cannot read aborts startup (closes #176): of the places pixa looks for its config file on its own, only one where the file does not exist is passed over; any other error, such as a directory on the path that pixa may not enter, aborts startup naming the file, as a file that does not parse already did.
- 2026-10-04
.golangci.ymlre-vendored from the canonical copy (closes #57): the deprecatedgomodguardis switched off, so lint runs print no deprecation warning; its successorgomodguard_v2runs with the shared module block list, anddepguardkeepsnet/http/httptestout 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-srcandstyle-srcare'self'only. The generator page's two inlineonclickhandlers moved intointernal/static/generator.js, attached withaddEventListener; the bundled Tailwind script, which built styles in the browser, is replaced by a small hand-writteninternal/static/style.csswith only the rules the login and generator pages use, the templates carrying a few plain class names in place of Tailwind's. No build step. The pages keep their layout, not every pixel of it. - 2026-10-04 deployment guide and example Caddy config (closes #89):
"Deployment" in
README.mdsays what the reverse proxy in front of pixa must do (terminate TLS; passHost,OriginandRefereron unchanged; setX-Forwarded-For, withtrusted_proxiesto match; wait at leastdownstream_timeout; optionally refuse/metrics) and what pixa does itself, that the state directory needs a persistent volume and whatcache_max_bytescounts, the health check for a load balancer, what a stop does and its exit codes, and what running outside Docker needs;configs/Caddyfileis the example, checked withcaddy validate. - 2026-10-04 the metrics basic auth, CORS preflight, request logging and
metrics recording have tests (closes #79):
MetricsAuthon 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 whenaccess_control_allow_originis*and noAccess-Control-Allow-Originfrom another origin than the configured one; aPOST /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/metricsand records requests when a metrics username is set. Only one test per package can set up/metrics, and ininternal/serverthat isTestMaintenanceModeKeepsOtherRoutes, 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),localhostas 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.txtand the health check. No code changes. - 2026-10-04 request IDs returned and passed on, and
/v1/e/revalidates (closes #84): pixa's ownRequestIDmiddleware, in place of chi's, gives each request an ID, its ownX-Request-IDwhen that is at most 64 letters, digits,-,_or.and a random one otherwise, stores it where chi's did and sends it back asX-Request-IDon every response; the upstream fetch sends that ID, and the "upstream fetched", "image converted" and "image served" log lines carry it asrequest_id, a fetch shared by several requests carrying the first request's;/v1/e/setsETag, answers a matchingIf-None-Matchwith 304 and is routed forHEAD, theETagand 304 code beingnotModified, which/v1/image/calls too; its token checks moved unchanged intoparseImageEncRequestto keepHandleImageEncwithin the line limit; noVaryis added, as no response depends on a request header except the image routes' CORS headers, for whichgo-chi/corsalready sendsVary: Origin;Vary: Acceptis left to #88. - 2026-10-04 routes, encrypted URLs and config file documented (closes #75):
"Routes" in
README.mdlists every route with its method, purpose, what it needs and the status codes it answers with, and saysqandfitare 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.ymllistsdb_urlandenvand gives every key's default;scripts/manual-test.shis left to #97. - 2026-10-04 shutdown stops cache eviction in progress (closes #102):
StartEvictionruns the eviction goroutine with its own context, whichStopEvictioncancels, 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;StopEvictiontakes 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/imgcacheis gone (closes #73):Purge, which only returned an error and which nothing called, is no longer part of theImageCacheinterface orService; theSignatureValidator,AllowlistandStorageinterfaces, which nothing implemented or used, are deleted. Nothing else changes. - 2026-10-04 upstream host semaphores and variant
.metafiles 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 semaphore once none is left, so fetches from many hosts no longer leave one semaphore each until restart;VariantStorage.Deleteremoves the variant's.metafile along with it, a missing.metafile not being an error, andDeleteWithMeta, which eviction called for that, is gone. - 2026-10-04
README.mdmatches the code (closes #74): "Storage" names the cache directories pixa uses (cache/sources,cache/metadata,cache/variants) and how files are named in each, and the comments in001_schema.sqlname the same paths; the routes and the signature section list the same output formats,jpgandoriginalincluded; the TLS sentence namesallow_httpas 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
Runincmd/pixadexits 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/cibuildandscript/dockerrun executes the checks (closes #101): theDockerfiledeclaresCHECK_EPOCHabovemake fmt-checkandmake lintin the lint stage and abovemake testin the build stage, and each of those steps names it in its command; both scripts pass a new value on every run, so Docker runs the checks instead of reusing cached results, while thescript/bootstrapsteps stay cached; a plaindocker build .still works, leaves it empty, and reuses the check steps only for an identical build context; thescript/cibuildcomment andREADME.mdno longer say that any successful build implies a green repo. - 2026-09-29 share concurrent misses (closes #65): requests that miss the same
variant at once (the same cache key, so quality and fit included) share one
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 whensentry_dsnis set and becomes an error for every waiting request instead of stopping pixad; documented inREADME.md. - 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
middleware, with the
access_control_allow_originorigin, moved from the router root onto a/v1subrouter holding/v1/image/and/v1/e/, where it still answers a preflightOPTIONSrequest; the login and URL generator pages,/metricsand the other routes send noAccess-Control-Allow-Origin; documented inREADME.mdandconfig.example.yml. - 2026-10-02 a plain
docker build .stamps the tag or short commit, notdev(closes #166):.dockerignorelets.gitinto the build context, without.git/config; with noVERSIONbuild argument theDockerfiletakes the version fromgit describe --tags --always, and fails the build if the context carries.gitand no version comes out;ARG VERSIONhas 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/pixausable by itself (closes #159):deploy/docker-entrypoint.shcreates the directory if it is missing, gives the directory and everything in it topixadwhen the directory or one of its top-level entries belongs to another user or group, sets its mode to750, then runs the server aspixad; data left by an earlier run under another uid is taken over this way; "Running under upaas" inREADME.mdno longer tells the operator to create or chown the host directory. - 2026-09-29 variant content types kept in memory (closes #70):
Cache.metaCacheholds the content types of up to 10,000 variants in an LRU (github.com/hashicorp/golang-lru/v2), filled byStoreVariantand byGetVariantafter it reads a.metafile, where a typeStoreVariantadded meanwhile is kept over the one read, and never with theapplication/octet-streamserved for a variant without one; for a variant it holds,GetVariantskips the.metaread, still opening the variant file and taking the size from it; eviction removes the entry before deleting the files, andGetVariantremoves it when the file will not open; the cap is a constant, not a setting; the unusedvariantMetatype is gone;README.mddescribes it. - 2026-09-29 maintenance mode refuses image requests (closes #71): while
maintenance_modeis on,/v1/image/and/v1/e/answer 503 with aRetry-Afterheader and the JSON error body, from one middleware ininternal/server/routes.go; the health check stays 200 and reportsmaintenance_mode, as the image's DockerHEALTHCHECKrequests it and upaas marks a deploy failed when its container is unhealthy; the login and URL generator pages and/metricskeep working; documented inREADME.mdandconfig.example.yml. - 2026-09-29 bound concurrent image processing and upstream fetches (closes
#64):
max_concurrent_processing(default the number of CPUs pixa can use) limits the images decoded and encoded at once, andupstream_connections(default 64) the connections to all upstream hosts together, on top ofupstream_connections_per_host; a fetch holds its connection until its image has been processed, and a request whose source is cached reads it only once it has a processing slot; a request that finds either limit reached waits up to 10 seconds for a free one, then gets 503server busy, try again later; libvips runs one worker thread per image with its operation cache off; documented inREADME.mdandconfig.example.yml. - 2026-09-29 Dockerfiles install through
script/bootstrap(closes #95): theDockerfilelint and build stages andDockerfile.lintcopyscript/,go.modandgo.sum, then runscript/bootstrapin place of their ownapk addlines, so the build dependencies are listed in one place;script/bootstrapnow also installs a C compiler whengccis missing; the build uses-trimpathand-s -wand keepsCGO_ENABLED=1for govips;ARG VERSIONsits just above the build, so a new version reruns neitherscript/bootstrapnor the tests. - 2026-09-29 migrations at the path
REPO_POLICIES.mdsets (closes #96): the migration files moved, contents unchanged, frominternal/database/schema/tointernal/db/migrations/as000_migration.sqland001_schema.sql; theinternal/db/migrationspackage embeds them andinternal/databasereads them through itsFS(); theinternal/databasepackage itself stays; the version still comes from the filename prefix, so a database that has recorded versions 0 and 1 runs neither again. - 2026-09-29
trusted_proxiesadvice and signature padding inREADME.md(closes #150): the login-limit paragraph, thetrusted_proxiesentry andconfig.example.ymlsay to settrusted_proxiesto the address pixa sees for requests that come through the proxy, which the request log shows asremoteIPwhile it is not trusted; for a proxy on the Docker host that connects over127.0.0.1that is the Docker network's gateway, not the proxy's own address; the signature section sayssigis base64url with the=padding kept, and gives the example'ssigfor a stated signing key. - 2026-09-29 fixed uid and gid for
pixad(closes #151): the image creates thepixadgroup with gid 65532 and thepixaduser with uid 65532, instead of the first free uid 1000, so a bind-mounted/var/lib/pixagiven topixadis not owned on the host by a person's login account; the first-run step of "Running under upaas" inREADME.mdnames the uid and gid. - 2026-09-29
max-agenever outlives an expiring URL (closes #63): both image routes buildCache-Controlfrom the request'sExpires, which an encrypted URL's expiry now fills too;max-ageis one year, or the whole seconds left until theexpof a/v1/image/URL or the expiry of an encrypted URL when that is sooner, never negative; an allowlisted host's URL that has anexpfollows it too;immutablestays, as freshness now ends at the expiry; documented inREADME.md. - 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, the size a whole number of bytes up to 1 GiB, the origin*or onehttporhttpsorigin asREADME.mddescribes it; an invalid value aborts startup naming the key and the value; documented inconfig.example.ymlandREADME.md. - 2026-09-28 cache stats report real numbers (closes #56):
Cache.Statscounts the cached source images and processed variants (source_contentplusvariant_content) and takes their size fromCache.UsageBytes, instead of readingrequest_cacheandoutput_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, soupstream_fetch_countandupstream_fetch_bytesmove, including for an upstream body that fails partway or a fetched source that then fails the magic byte check;transform_countcounts 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 withAutoRotate(before sizes are worked out) and, when it has an ICC profile, converted to sRGB; theorigformat is re-encoded and stripped like any other, as pixa never serves the source bytes; there is no setting to keep metadata; documented inREADME.md. - 2026-09-28 rate limit the login form (closes #66):
POST /is limited to 5 attempts per minute per client address, and an attempt over the limit is refused with 429 and aRetry-Afterheader; the address is the oneinternal/clientipresolves throughtrusted_proxies, an IPv6 client is counted by its /64, and an IPv4-mapped address as the IPv4 address it carries; the limit is aRateLimitmiddleware ininternal/middlewareongithub.com/go-chi/httprate, which the image routes can reuse; the library keeps counts for the current and the previous minute only; documented inREADME.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
- P2: security
- per-origin rate limiting
- P2: HTTP response handling
- Last-Modified headers
- Vary header for content negotiation
- 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
- load tests to verify the 1k to 5k req/s target