Commit Graph
178 Commits
Author SHA1 Message Date
sneak b984b27cf8 Run the reconciliation pass at startup and once a day (closes #250)
check / check (push) Waiting to run
The pass makes a database query for every cached file and ran on every
eviction tick, so on a large cache one pass could outlast the tick and
the next started at once. It now runs on a timer of its own that starts
when a pass ends, a day long. Eviction and the removal of expired
negative cache rows keep the eviction interval.

Model: opus-5-5
2026-10-15 20:43:30 +00:00
sneak af6ab4a668 Test that eviction ticks read nothing for reconciliation
The eviction loop runs the reconciliation pass on every periodic tick,
so this test fails until the pass runs at startup and then once a day.

Model: opus-5-5
2026-10-15 20:42:35 +00:00
clawbot 4e30dc7467 Re-vendor the canonical files from sneak/prompts at e3f79c4 (closes #221)
check / check (push) Canceled after 0s
The dotfiles, REPO_POLICIES.md and the standard scripts are fetched
whole from sneak/prompts e3f79c4, with pixa's own entries kept after
them. Check builds pass a new CACHEBUST build argument instead of
--no-cache, and every Dockerfile stage declares it first, so every RUN
step still runs while Go's build cache is kept. Lint runs golangci-lint
v2.14.0, and the test phase runs on the Debian Go image. Of the two new
lint findings, one is fixed and one (gosec G703 on the fixed config file
locations) is suppressed. The agent guidance moves unchanged from
CLAUDE.md to AGENTS.md.

Deviation: the workflow keeps its docker-smoke step while issue 203
waits on the owner.

Model: opus-5-5
2026-10-15 22:40:38 +02:00
clawbot 16f9d1dfd1 Label request metrics with standard HTTP methods only (closes #274)
check / check (push) Waiting to run
The metrics middleware labelled each request with its method as sent,
and Go's HTTP server accepts any method name that is a valid token, so
each made-up method a client sent added series that were never removed.
The method label is now the request's method only for the nine standard
methods, and "other" for any other. The middleware hands the metrics
library a copy of the request carrying the label and serves the next
handler the original, so handlers still see the method the client sent.

Model: opus-5-5
2026-10-15 21:47:11 +02:00
clawbot 742db9ceb1 Logging out ends every login session (closes #256)
check / check (push) Waiting to run
A login session was a cookie with nothing kept on the server, so
logging out removed it from one browser and a copy kept working for 30
days. The session manager now keeps a random value in the database,
made when it has none and replaced by each logout; a session carries
the value current at its login and is refused once it has changed. The
logout check compares no time, so the host's clock cannot block a
login, and losing the state directory ends every login session.
Logging out is now a POST with the CSRF token, sent by a form on the
generator page.

Model: opus-5-5
2026-10-15 20:44:44 +02:00
clawbot 2ab96155ba Sync cached files and refuse ones left empty or partial (closes #252)
check / check (push) Waiting to run
A crash could leave a cached source or transformed image empty or cut
short, and pixa then served it, or fetched it again forever. Source and
transformed image files, and the .meta file beside each transformed
image, are now written under a temporary name, synced, renamed into
place and their directory synced; a failed directory sync removes the
renamed file. The reconciliation pass compares each cached file's size
with the size its row records and removes a file that is empty or of
another size, with its rows and a warning. Requests compare no sizes.

Not verified by a test: the syncs, and the removal after a failed sync.

Model: opus-5-5
2026-10-15 19:27:33 +02:00
clawbot 3ba9ae9c39 Stop a transcode whose request has ended before it encodes (closes #243)
check / check (push) Waiting to run
Process looked at its context only while waiting for a processing
slot, so a transcode kept its slot and worked on after its request's
downstream_timeout. acquireSlot now gives the slot back when the
context has ended by the time it has one, and encode returns the
context's error without beginning. govips gives pixa no way to stop
libvips once an encode has begun, so that encode keeps its slot. A
request waiting on a shared transcode stopped at the first request's
deadline is answered 503, not 500. README.md "Routes" says both.

Model: opus-5-5
2026-10-15 18:44:11 +02:00
clawbot 7956da1f59 Label request metrics by route, not by path (closes #254)
check / check (push) Waiting to run
The metrics middleware labelled each request with its path, so every
new path added series to the registry that were never removed, and
/metrics listed the token of every encrypted URL requested. The handler
label is now the pattern of the chi route the request will match, looked
up before routing, or "unmatched" when none does. /v1 and /v1/ ask the
image subrouter, and an empty path is looked up as /, as chi routes it.
TestMetricsRecordsServedRequest expects "unmatched" in place of "/test",
as the owner approved.

Model: opus-5-5
2026-10-15 16:37:20 +02:00
clawbot 69f65d4302 Remove expired negative cache rows in the eviction loop (closes #244)
check / check (push) Waiting to run
A negative cache row was deleted only when its URL was requested
again, so a client asking for a new missing path each time grew the
database without limit. The eviction goroutine now removes expired rows
at startup and on every tick, so a row stays about NegativeTTL plus one
eviction interval. It deletes at most 1000 rows per statement, so a
request on the one database connection waits behind one batch at most.
The goroutine now also runs when the disk cache is disabled, since the
negative cache is used either way.

Model: opus-5-5
2026-10-15 15:02:27 +02:00
clawbot 2790125cb2 Serve a cached variant while its source URL is in the negative cache (closes #245)
check / check (push) Waiting to run
Get checked the negative cache before the variant cache, so after one
failed fetch of a source every variant of it answered 502 for five
minutes, also one on disk or one that could be made from the cached
source. The check now sits at the start of fetchAndProcess, which runs
only when no cached source can be used. A refusal from the negative
cache now counts as a miss.

Model: opus-5-5
2026-10-15 13:45:25 +02:00
clawbot c23585dd6d Lower-case the image URL's host and require a signature for a port (closes #246)
check / check (push) Waiting to run
With `cdn.example.com` allowlisted, other letter cases and a host with a port also passed the allowlist; each spelling was fetched and cached on its own with its own per-host connection limit, and a port let an unsigned URL reach any port of the host.

The image URL parser now lower-cases the host and refuses one holding `@` with 400. A host followed by `:`, with or without a port (read as Go's URL parser reads it, unbracketed IPv6 included), never counts as allowlisted, so it needs a signature. The host is signed lower-cased, as the URL writes it, so a signature made over an upper-case spelling is refused with 401.

Model: opus-5-5
2026-10-15 12:31:14 +02:00
clawbot 5d1e3ea871 Escape : and % in the signed and cached path and query (closes #247)
check / check (push) Waiting to run
The signed data and the cache key joined host, path and query with ":" and no escaping, so path /a:b with no query and path /a with query b: gave the same string: a URL signed for https://h/a:b verified for https://h/a?b:, and the two shared a cached image. Both now write each % in the path and the query as %25 and each : as %3A before joining them. The host never holds / and the path always starts with /, so the joined string reads back one way only. A path and query holding neither character are signed and keyed as before, so the existing known-answer vectors and the README example are unchanged; new vectors cover the escaped cases. Signature Specification in README.md states the escaping.

Model: opus-5-5
2026-10-15 09:37:01 +02:00
clawbot 761ade2e36 Accept a chunked upstream body of exactly the maximum size (closes #248)
check / check (push) Waiting to run
Once upstream_max_response_size bytes were read, the fetcher refused the next read without looking for the end of the body. Go's HTTP client returns the end of a body sent with a Content-Length together with its last bytes, but the end of a chunked body can come in a later read, so a chunked body of exactly the limit was refused after it was downloaded whole and the image routes answered 500. Once the limit is used up, the fetcher now reads one more byte: if the body has ended it is accepted, and if there is more it is refused with ErrResponseTooLarge.

Model: opus-5-5
2026-10-15 07:44:09 +02:00
clawbot 88e1b1cdfa Allowlist no host in the example config (closes #249)
check / check (push) Waiting to run
The example config allowlisted github.com and user-images.githubusercontent.com, where anyone can put an image, so a deployment set up as Getting Started says served every image there to anyone without a signature. The example now allowlists nothing and shows one commented entry under example.com. README.md says in Getting Started and in "Running under upaas" that every image on an allowlisted host is served without a signature, so an allowlisted host must be one the operator controls. A new test loads the example config and fails if it allowlists any host.

Model: opus-5-5
2026-10-15 06:26:23 +02:00
clawbot afb5dc5d0d Refuse a fit=outside size whose output exceeds the maximum (closes #257)
check / check (push) Successful in 15m51s
fit=outside scales the source to cover the requested size, and the side
scaled past it was held to no maximum: 1x8192?fit=outside of a 3000x600
source was resized to 40960x8192 and encoded. resize now refuses such an
output with ErrOutputTooLarge before resizing, so both image routes answer
it 400 with the error a size with one side 0 already gets. The other fit
modes make the requested size or one within it. README.md and TODO.md
say so.

Model: opus-5-5
2026-10-13 10:44:45 +02:00
clawbot d8760c40a1 Refuse a size whose computed side exceeds the maximum (closes #241)
check / check (push) Successful in 14m47s
A side given as 0 is computed from the source's proportions, and
nothing held it to 8192: 0x8192 of a 3000x600 source was resized to
40960x8192 and encoded. Process now refuses such a size with
ErrOutputTooLarge once it knows the source's dimensions, before the
resize and the encode, and both image routes answer it 400 with the
JSON error body. It is a processing error, not a failed fetch, so it
is never negative-cached. imgcache.MaxDimension is now
imageprocessor.MaxOutputDimension, so the request limit and the output
limit are one value. README.md says it under Routes and Encrypted URLs.

Model: opus-5-5
2026-10-13 09:44:44 +02:00
clawbot ca512fdb4f Remove the unused cache lifetime setting (closes #69)
check / check (push) Canceled after 0s
CacheConfig.CacheTTL was set to DefaultCacheTTL (24 hours) and never
read, so cached images never expired. Remove the field, the constant
and the line that set it. Cached source and transformed images stay
until eviction removes them to keep the cache within cache_max_bytes.

A new test caches a source and a transformed image, sets every time
pixa stores for them two days back (database rows, metadata files and
file times), and checks that both are still served from the cache
without a new upstream fetch.

Existing tests lose only the CacheTTL line of their cache config.

Model: opus-5-5
2026-10-13 07:58:55 +02:00
clawbot 52f69d3082 Refuse SVG sources and never save an unknown input as JPEG (closes #68)
check / check (push) Canceled after 0s
The upstream fetch no longer accepts image/svg+xml, and both image
routes answer 415, not 500, for any upstream Content-Type outside the
source formats. Before decoding, the image processor names the input's
format from govips' DetermineImageType and refuses, with 415, input
whose first bytes libvips reads as anything but the six source formats,
so an SVG passing the first-bytes check for AVIF never reaches librsvg.
orig of such an input is refused instead of being saved as JPEG.
README.md drops SVG from the source formats.

SVG detection stays in internal/magic, unused on the request path, for
#168. An AVIF image sequence
(brand avis) answers 415.

Model: opus-5-5
2026-10-13 07:13:17 +02:00
clawbot 162a2a33ef Keep counts not written by the request's deadline in memory (closes #224)
check / check (push) Successful in 32m7s
TestService_Get_ReturnsByItsDeadline failed on a busy host because its
request, past its deadline, still waited to write its miss count, a write
with no deadline; in pixad that write waits for the one database
connection every request shares. Each count write now keeps the request's
deadline but not its cancellation. A count not written by then is kept in
memory, where Stats includes it, and one goroutine of the cache writes
those counts one UPDATE at a time, and once more at shutdown before the
database closes. The test phase also runs go test with -parallel 4: on a
busy host, as many tests at once as there are CPUs wait so long to be
scheduled that a timed request can fail.

Model: opus-5-5
2026-10-13 03:07:23 +02:00
clawbot 40ad39843b Limit each client's requests to the image routes (closes #236)
check / check (push) Successful in 54m15s
The new setting image_requests_per_minute (PIXA_IMAGE_REQUESTS_PER_MINUTE),
default 600, caps the GET and HEAD requests one client sends to /v1/image/
and /v1/e/ per minute; the next is answered 429 with Retry-After and the
image routes' JSON error body. It uses the login limit's RateLimit
middleware, so clients are told apart the same way, behind the CORS
middleware, so a preflight is not counted. 0 turns it off, as
script/loadtest does; a negative or non-integer value stops startup.

TestEnvironmentSetsEveryKey gains the new variable, under the standing
approval in #64. To stay within the
linters' limits, the sentry_dsn check moves unchanged into
validateSentryDSN, and the cache_max_bytes explicit flag is set in one
line.

Model: opus-5-5
2026-10-13 01:36:18 +02:00
clawbot bdde021b45 Save PNG compressed, WebP at effort 4 and AVIF at effort 1 (closes #232)
check / check (push) Canceled after 0s
Each output format now has its own govips export with its settings
named, in place of govips' generic Export, which sent libvips a zero for
some settings it was not given: PNG had no compression and WebP effort
0. PNG now gets libvips' default compression, 6, and WebP its default
effort, 4. GIF and JPEG output is unchanged.

AVIF was at libvips' default effort, 4, which takes minutes for an
8192x8192 image with one libvips thread, far past the default
downstream_timeout. Effort 1, the lowest govips can set, takes about 51
seconds for an image of random pixels, the worst case, and WebP at 4
about 43. A 16-bit source gets 8 bits per sample, not libvips' 12, which
take over four times as long.

Model: opus-5-5
2026-10-08 14:13:25 +02:00
clawbot db91ab29e6 Serve JPEG XL when a request names no format (closes #222)
check / check (push) Canceled after 0s
A /v1/image/ URL whose last segment is a size with no format, such as
800x600 or orig, is served as JPEG XL and signed as jxl, so it shares
the signature of the same URL ending in .jxl. An encrypted URL whose
token holds no format is served as JPEG XL, as encurl.DefaultFormat is
now jxl. The generator page selects JPEG XL by default, and a form
with an empty format, or none, makes a URL whose name ends in .jxl.

The image processor no longer takes an empty format as orig: both
routes give every request a format, so it refuses a request with none
instead of keeping a second default. auto still ends with JPEG.

Model: opus-5-5
2026-10-08 12:49:56 +02:00
clawbot 8597253ffd Serve and accept JPEG XL as an image format (part of #222)
check / check (push) Canceled after 0s
A JPEG XL source is accepted, and orig of one is JPEG XL. The format
jxl works in plain and encrypted URLs and on the generator page,
served as image/jxl; auto chooses it first when Accept names
image/jxl.

govips sends libvips a JPEG XL distance, which overrides the quality,
so q becomes a distance, keeping 100 lossy. Metadata is removed from
the image before the JPEG XL save, as govips cannot have libvips strip
it, and the image is given 72 dpi so that the EXIF block libvips 8.16
adds holds nothing from the source. The sRGB conversion moved ahead of
the format switch, and a CMYK image with no ICC profile is converted
to sRGB, as libvips cannot save CMYK as JPEG XL.

Model: opus-5-5
2026-10-08 11:01:12 +02:00
clawbot d2944aa891 Eviction no longer reads a whole table while requests wait on the database (closes #227)
check / check (push) Canceled after 0s
UsageBytes now reads the new cache_usage row, which triggers on
source_content and variant_content keep up to date in the statement
that adds, removes or resizes a row. The reconciliation pass reads both
tables 1000 rows per query, sums them, and corrects the total when it
differs, unless a row changed while it summed. Source rows now get
last_accessed_at when added, so choosing source images to evict reads
that column's index instead of sorting the whole table. Stats still
sums the tables, now in pages: an existing test drops both tables and
expects that sum to fail.

Model: opus-5-5
2026-10-08 07:12:02 +02:00
clawbot 54328377d0 Install libvips JPEG XL support and require it at startup (part of #222)
check / check (push) Canceled after 0s
script/bootstrap --cgo installs vips-jxl when the package manager it
uses is apk, as Alpine's vips package lacks JPEG XL support; the nix
and brew libvips include it, as does apt's from Debian 12 and Ubuntu
24.04 on. The runtime stage of the Dockerfile installs vips-jxl too.

imgcache.NewService calls the new imageprocessor.CheckJPEGXLSupport
before it builds the image processor and fails with its error, which
names the fix, so pixad does not start without the support. JPEG XL
becomes the default output later in the issue. New tests check the
support and save an image as JPEG XL with govips and load it back.

Model: opus-5-5
2026-10-08 06:19:05 +02:00
clawbot 5058fd532b Queue SQLite writes on one connection so none fails as locked (closes #223)
check / check (push) Canceled after 0s
internal/database now opens the database with one connection. pixa's
own reads and writes run on it one at a time instead of competing for
SQLite's lock, where a write that kept losing could wait past the
five-second busy timeout and fail with "database is locked". The busy
timeout stays, for another program writing to the same file.

With one connection, a query run while rows or a transaction are still
open would wait forever. No code in internal/database or
internal/imgcache does that; the comment on DB() tells callers.
README.md says pixa uses one connection and that requests wait while
eviction runs one of its queries.

Model: opus-5-5
2026-10-08 04:50:31 +02:00
clawbot 15d95436a5 Serve the format auto, chosen from the Accept header (closes #88)
check / check (push) Failing after 3s
auto is a format in the /v1/image/ path, an encrypted URL's token and
the generator page. Once the signature or token is checked, pixa
chooses AVIF when Accept names image/avif, else WebP when it names
image/webp, else JPEG when the most specific of image/jpeg, image/* and
*/* allows it or Accept is absent. q=0 refuses a format; a header
allowing none of the three answers 406, one that does not parse 400.
The signature and token cover auto itself; the cache key and ETag use
the chosen format. Answers from then on carry Vary: Accept.

Model: opus-5-5
2026-10-05 05:24:50 +02:00
clawbot 55cf7f4fac Add script/loadtest to measure throughput, latency and memory (closes #81)
check / check (push) Failing after 2s
script/loadtest [duration [clients]] (make loadtest, defaults 10s and 4)
measures pixad in three scenarios: hit (one cached image), miss (a new
source image every request) and herd (each new source image asked for
by all clients at once). For each it prints vegeta's report (requests
per second, p50/p95/p99 latency, status codes), pixad's peak resident
memory and how many requests reached the origin. It is a benchmark, not
run by script/check. The origin it uses, internal/loadtestorigin behind
cmd/loadtest-origin, answers every path with one generated JPEG. Bad
arguments are refused before the build. README.md says how to run and
read it and keeps 1-5k r/s as a target not yet measured at scale.

Model: opus-5-5
2026-10-05 02:58:41 +02:00
clawbot 2beba15ae7 Move pixad's startup from cmd/pixad into internal/app (closes #206)
check / check (push) Failing after 2s
cmd/pixad/main.go built the command line and its --config flag, set
PIXA_CONFIG_PATH from that flag, ignored SIGPIPE and started the fx
app. REPO_POLICIES.md now requires cmd/ to hold only one call into
internal/ or pkg/, so that code moves unchanged to Run in the new
internal/app package, and main calls app.Run(Version). Version stays
in main, so the -X main.Version build flags in the Dockerfile and the
Makefile do not change.

Model: opus-5-5
2026-10-05 01:07:47 +02:00
clawbot cca2e3f926 Test the image proxy flow end to end (closes #80)
check / check (push) Failing after 2s
TestImageProxyFlow in internal/server starts the database, handlers and
middleware from the constructors pixad uses, with a fresh state
directory, and replaces only the upstream origin with an httptest
server. For a resize with a format change and for orig it checks a 200
MISS with the right type and size, then a HIT after one upstream request,
and the files and rows the cache keeps. Two optional test seams make
that possible: httpfetcher.Config.DialContext and handlers.Params.Fetcher.
pixad sets neither and the config file and environment cannot, and tests
show production still uses the checked dialer and builds its own fetcher.

Model: opus-5-5
2026-10-04 23:24:46 +02:00
clawbot 708a9bec20 Test that a URL made on the generator page with a ttl expires (closes #199)
check / check (push) Failing after 2s
A new handler test 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 expiry is kept in whole seconds,
so two seconds is the longest a one-second ttl can take to pass. Test
only.

Model: opus-5-5
2026-10-04 22:42:03 +02:00
clawbot 8314099abd Refuse image requests whose Referer is on referer_blocklist (closes #90)
check / check (push) Failing after 2s
A new setting, referer_blocklist (PIXA_REFERER_BLOCKLIST), lists hosts
whose pages may not show pixa's images. Entries are written and matched
as allowlist_hosts are, with the same matcher. Both image routes check
the Referer before the signature, the cache and the upstream fetch, and
answer 403 with the JSON error, so a blocked request costs nothing and
is refused whether or not the image is cached. No Referer, or one that
does not parse, is served; README.md says this makes the list easy to
get around. An entry of either host list that is not a host name
(letters, digits, hyphens, underscores, dots, at most one leading dot)
or an IP address now aborts startup naming the setting and the entry.

Model: opus-5-5
2026-10-04 22:24:50 +02:00
clawbot 8568c17d1b Move the example config to configs/, delete scripts/ and CONVENTIONS.md (closes #97)
check / check (push) Failing after 2s
config.example.yml moves unchanged to configs/config.example.yml, the
directory REPO_POLICIES.md names for configuration examples. 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. 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 (#199).
CONVENTIONS.md, a reformatted copy of the Go HTTP server conventions, is
deleted, as REPO_POLICIES.md links the canonical document.

Model: opus-5-5
2026-10-04 21:07:52 +02:00
clawbot 625fd42ace Wait on a busy SQLite database and turn on WAL mode (closes #198)
check / check (push) Failing after 2s
Requests and the eviction pass write on separate connections, and with
no busy timeout a write that met another one failed at once with
"database is locked" and was lost. internal/database now adds
_pragma=busy_timeout(5000) to every db_url, the default or one the
operator sets, so such a write waits up to five seconds. The default
db_url's _journal_mode=WAL is not a parameter the driver reads, so it
is now _pragma=journal_mode(WAL). README.md and config.example.yml say
what pixa adds to db_url.

Model: opus-5-5
2026-10-04 20:58:37 +02:00
clawbot 66e71b4207 Make the periodic reconciliation test wait for the startup pass (closes #189)
check / check (push) Failing after 2s
TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup slept for
three eviction intervals before writing its file, so on a slow start the
startup pass could still be running and adopt the file itself, and the
test passed without a periodic pass. It now holds the test database's
only connection until the startup pass waits for it, writes the file and
lets the connection go, as TestEvictionRunsOnPeriodicSchedule does, so
only a periodic reconciliation pass can adopt the file. Test only.

Model: opus-5-5
2026-10-04 19:59:36 +02:00
clawbot 847ad5b428 Count what the cache holds in the default cache_max_bytes (closes #184)
check / check (push) Failing after 1s
The default cache_max_bytes was 75% of the space free at startup. The
cache's own files are not free space, so a fuller cache got a smaller
limit after a restart and eviction then deleted most of it. The default
is now 75% of the sum of the free space and what the cache already holds
by its own size accounting, at least 500 MiB. The cache works it out
when it opens, after the database is open, so the computation and its
tests moved from internal/config to internal/imgcache. The config only
records whether cache_max_bytes was set; newCacheConfig in the handlers
turns the disk cache off only for an explicit 0, and is tested for an
omitted, a zero and a positive value.

Model: opus-5-5
2026-10-04 19:41:49 +02:00
clawbot 233a9c05ad Test logging in, logging out, the URL generator and /v1/e/ tokens (closes #77)
check / check (push) Failing after 4s
New handler tests in internal/handlers, with no network: GET / shows the
login form without a session; a wrong key shows it again with an error and
sets no session cookie; the right key answers 303 with a session cookie
marked Secure, HttpOnly and SameSite=Strict, with which GET / shows the
generator page; GET /logout empties the cookie with Max-Age=0; POST
/generate without a session answers 303 to /; /v1/e/ serves a valid
token's image, answers 410 for an expired token and 400 for one changed,
cut short or made with another signing key; a URL made on the generator
page is served by /v1/e/. No code changes.

Model: opus-5-5
2026-10-04 19:24:39 +02:00
clawbot 842372250f Remove unsafe-inline from the Content-Security-Policy (closes #125)
check / check (push) Failing after 2s
script-src and style-src now allow only 'self'. The generator page's
two inline onclick handlers, which selected the generated URL and
copied it, move into internal/static/generator.js and are attached
with addEventListener. The bundled Tailwind script, which built styles
in the browser and injected them at runtime, is replaced by a small
hand-written internal/static/style.css holding only the rules the
login and generator pages use; the templates carry a few plain class
names in place of Tailwind's. No build step. The pages keep their
layout, not every pixel of it.

Model: opus-5-5
2026-10-04 18:58:40 +02:00
clawbot 48f21d4ecf Abort startup on a config file pixa cannot read (closes #176)
check / check (push) Failing after 4s
Of the places pixa looks for its config file on its own, it passed over
any place where os.Stat failed, so a file in a directory pixa may not
enter was skipped without a word and pixa started on a later file or on
the environment and defaults. Now only a path that does not exist, or
that runs through a file (such as under a HOME of /dev/null), is passed
over; any other error aborts startup naming the file, as a file that
does not parse already did. README.md says so where it gives the search
order. A config.yml that links to itself tests this as root too.

Model: opus-5-5
2026-10-04 18:24:43 +02:00
clawbot 04093f53ad Stop TestEvictionRunsOnPeriodicSchedule racing the evictor (closes #183)
check / check (push) Failing after 1s
The test wrote each variant file and then inserted its accounting row by
hand while the evictor was running. A reconciliation pass between the two
steps adopted the file first, and the hand insert failed on the unique key.

The test now writes the files only, while it holds the test database's
only connection, so the evictor's startup pass waits after walking the
still empty variant directory. A periodic reconciliation pass then adopts
the files and the eviction pass after it evicts them; no write-pressure
notification fires. The test waits until two of the three files are gone,
then checks that usage is within the limit and that no row points at a
missing file.

Model: opus-5-5
2026-10-04 16:58:34 +02:00
clawbot 604b51eea6 Test metrics auth, CORS preflight, login logging and metrics (closes #79)
check / check (push) Failing after 1s
New tests only. 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 CORS preflight request gets the same
Access-Control-Allow-Origin as a GET. A POST / carrying the signing key
leaves the key out of 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; the router records nothing while no metrics
username is set.

Not tested through the router: the basic auth in front of /metrics and
recording with a metrics username set (#180).

The pinned basicauth-go compares the password in constant time.

Model: opus-5-5
2026-10-04 14:39:27 +02:00
clawbot ba5a716223 Test the image route's signature check and error answers (closes #76)
check / check (push) Failing after 1s
New tests in internal/handlers, with no network: the status and JSON
error body the image route answers 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; an unparseable path, localhost
as the upstream host, and an upstream error; that an allowlisted host
is served without a signature and another host only with a valid one;
and the answers of /robots.txt and the health check. An expired
signature is answered 401, as the code and README.md say, where the
issue body expected 410. No code changes.

Model: opus-5-5
2026-10-04 13:58:29 +02:00
clawbot c7173c47d8 Return and pass on request IDs, and give /v1/e/ ETag, 304 and HEAD (closes #84)
check / check (push) Failing after 2s
Every response carries X-Request-Id, the upstream fetch sends it, and
the "upstream fetched", "image converted" and "image served" lines log
it as request_id. pixa's own RequestID middleware keeps a request's own
ID only when it is at most 64 letters, digits, '-', '_' or '.', and
otherwise makes a random one with crypto/rand, so nothing a client
chooses freely and nothing about the host reaches upstream. /v1/e/ now
sets ETag, answers a matching If-None-Match with 304 and is routed for
HEAD, through notModified, which both image handlers call. No Vary is
added: go-chi/cors already sends Vary: Origin.

Model: opus-5-5
2026-10-04 12:41:54 +02:00
clawbot 1616e91a6a Stop cache eviction in progress at shutdown (closes #102)
check / check (push) Failing after 2s
StartEviction runs the eviction goroutine with its own context, which
StopEviction cancels in place of the old stop channel, so a pass in
progress stops at its next database call, file, row or eviction
candidate instead of running to completion, and no new pass starts.
StopEviction takes a context: when it ends before the goroutine exits,
StopEviction stops waiting and returns an error wrapping it. The
handlers' stop hook passes fx's stop context, so an eviction still
running at fx's stop deadline fails the stop and the exit code is 1.
A stop logs at most one warning.

Model: opus-5-5
2026-10-04 09:24:42 +02:00
clawbot 6830bdc5de Remove unimplemented Purge and three unused interfaces (closes #73)
check / check (push) Failing after 2s
Purge only returned an error and nothing called it, so it is gone from
the ImageCache interface and from Service, along with its error value.
It can be added back when something needs it; eviction reclaims disk
space meanwhile. The SignatureValidator, Allowlist and Storage
interfaces in imgcache.go had no implementers and no users, and
described types that look different from the real ones, so they are
deleted. No behaviour changes.

Model: opus-5-5
2026-10-04 08:41:37 +02:00
clawbot 5b17d1f555 Remove idle host semaphores and delete .meta with its variant (closes #87)
check / check (push) Successful in 10m10s
Each upstream host's semaphore now counts the fetches holding or waiting
for one of its slots, and is removed from hostSems when the last of them
gives its slot back or stops waiting, so a long-running pixad no longer
keeps one semaphore per host it ever fetched from.

VariantStorage.Delete removes the variant's .meta file too, a missing one
not being an error; DeleteWithMeta, which eviction called for that, is
gone.

Model: opus-5-5
2026-10-04 07:11:31 +02:00
clawbot 3a274aaa44 Make the README's storage, formats, TLS and metrics match the code (closes #74)
check / check (push) Successful in 10m10s
"Storage" names the cache directories pixa uses (cache/sources,
cache/metadata, cache/variants) and how files are named in each; the
schema comments name the same paths, and the output_content comment says
the table is not written. Routes and the signature section give one list
of output formats, jpg and original included, and say how those two are
signed. The TLS sentence names allow_http as its exception. "Metrics"
says what exists: generic HTTP and Go runtime metrics at /metrics,
measured and served only when the metrics username and password are set.

Model: opus-5-5
2026-10-04 06:24:36 +02:00
clawbot 00da62db2c Exit with the shutdown's code and wait for image processing (closes #86)
check / check (push) Successful in 9m56s
fx alone handles SIGINT and SIGTERM; the server's own handler, which
only cancelled a context that fx's stop did not wait for, is gone.
fx's Run exits with the shutdown's code: the one a shutdown request
carries, 0 for a signal, 1 when the app fails to start or stop.
A listen error asks fx to shut down with exit code 1. A Sentry DSN that
cannot be used fails the server's start hook, so fx stops what had
already started. The server's stop hook stops the HTTP server, then
waits for the images still being processed, both within
ShutdownTimeout; images still being processed after that are logged and
fail the stop, so the exit code is 1.

Model: opus-5-5
2026-10-04 04:59:30 +02:00
clawbot 697c14633b Share one fetch and transcode among concurrent misses (closes #65)
check / check (push) Successful in 6m19s
Requests that missed the same variant at once each fetched and
transcoded it. They now share one call through
golang.org/x/sync/singleflight, keyed on the variant cache key. The
first request processes the variant with a context that ignores its
cancellation but keeps its deadline; the others wait for its result,
holding no connection or processing slot, and return as soon as their
own context ends. The processing request waits even then, up to its
deadline. Each request counts one miss; the processing counts its fetch
and transcode once. A panic while processing is reported through the
request's Sentry hub, when there is one, and becomes an error instead
of stopping pixad.

Model: opus-5-5
2026-10-04 01:58:48 +02:00
clawbot 021516e099 Send CORS headers only from the image routes (closes #98)
check / check (push) Successful in 4m34s
The CORS middleware with the `access_control_allow_origin` origin wrapped every route from the router root, so the login and URL generator pages and `/metrics` sent `Access-Control-Allow-Origin` too. It now wraps only `/v1/image/` and `/v1/e/`, which form a `/v1` subrouter so that a browser's preflight `OPTIONS` request still gets its answer; the maintenance mode group moved inside it unchanged. Every other route sends no CORS headers. A new test checks both image routes, a preflight included, and the login and URL generator pages. `README.md` and `config.example.yml` say the setting covers the image routes only.

Unverified by test: `/metrics`, whose middleware registers with the process-wide Prometheus registry.

Model: opus-5-5
2026-10-03 17:49:43 +02:00