Format the markdown with prettier in script/fmt and script/fmt-check (closes #100)
check / check (push) Failing after 3s

script/fmt and script/fmt-check run prettier 3.8.1 on the markdown after
gofmt, with the same yarn helper and arguments as the copies in
sneak/prompts. prettier is pinned in package.json and yarn.lock;
.prettierrc sets four-space tabs and proseWrap always, and
.prettierignore keeps prettier off REPO_POLICIES.md and vendor/. Plain
script/bootstrap installs Node and Yarn the way the one in sneak/prompts
does; with --cgo it does not, as the Dockerfile stages that pass it
format nothing. The HTML templates stay out: prettier cannot parse a Go
template action inside a tag. This commit also holds the reflow that
make fmt then produced (lines rewrapped, bullets as dashes, no word
changed), which the PR kept as a separate commit for review.

Model: opus-5-5
This commit was merged in pull request #219.
This commit is contained in:
2026-10-05 04:24:45 +02:00
parent a941a80bf9
commit 01823d27db
10 changed files with 710 additions and 589 deletions
+333 -349
View File
@@ -1,28 +1,27 @@
# Workflow
* branch per issue from `next`
* do the work in Next Step
* move Next Step to the top of Completed Steps
* `TODO.md` merges with git's union merge (`.gitattributes`), which never
- branch per issue from `next`
- do the work in Next Step
- move Next Step to the top of Completed Steps
- `TODO.md` merges with git's union merge (`.gitattributes`), which never
reports a conflict: read the merged entries after every merge or rebase
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* open a PR based on `next`
* an independent reviewer who did not write the change gates it
* the manager squash-merges the PR into `next` once review passes
* `next` stays green and mergeable to `main` at any time; only the owner
merges `next` into `main`, via the single milestone PR
* push
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- open a PR based on `next`
- an independent reviewer who did not write the change gates it
- the manager squash-merges the PR into `next` once review passes
- `next` stays green and mergeable to `main` at any time; only the owner merges
`next` into `main`, via the single milestone PR
- push
# Status
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work
lands on `next`, and `main` receives only the milestone PR that the
owner merges. `next` is at the canonical `golangci-lint` v2.12.2 config
and is green. Recent work extracted the internal/magic,
internal/allowlist, internal/httpfetcher, and internal/signature
packages. The gosec findings from the 2026-07-06 survey are resolved.
The disk cache is now size-bounded with LRU eviction
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on
`next`, and `main` receives only the milestone PR that the owner merges. `next`
is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work
extracted the internal/magic, internal/allowlist, internal/httpfetcher, and
internal/signature packages. The gosec findings from the 2026-07-06 survey are
resolved. The disk cache is now size-bounded with LRU eviction
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
# Next Step
@@ -31,6 +30,16 @@ P2: security: per-IP rate limiting on the image routes
# Completed Steps
- 2026-10-05 the markdown is formatted with prettier (closes #100): `script/fmt`
and `script/fmt-check` run prettier 3.8.1, pinned in `package.json` and
`yarn.lock`, on `**/*.md` after `gofmt`, with four-space tabs and
`proseWrap: always` as `.prettierrc` says; `.prettierignore` keeps it off
`REPO_POLICIES.md`, the copy from `sneak/prompts`, and `vendor/`. Plain
`script/bootstrap` installs Node and Yarn as the one in `sneak/prompts` does
and then prettier; `script/bootstrap --cgo` does not, as the `Dockerfile`
stages that run it format nothing. The HTML templates stay unformatted:
prettier cannot parse a Go template action inside a tag. The markdown was
reflowed in a commit of its own.
- 2026-10-05 lint and tests run as the `lint` and `test` phases of the
`Dockerfile`, built with `--no-cache` (closes #202): `script/check`,
`script/cibuild`, `script/docker`, `script/lint`, `script/test`,
@@ -103,13 +112,12 @@ P2: security: per-IP rate limiting on the image routes
(the workflow's `script/docker-smoke` step),
https://git.eeqj.de/sneak/pixa/issues/204 (`.claude/` in `.gitignore`),
https://git.eeqj.de/sneak/pixa/issues/205 (`.dockerignore` patterns at every
depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin
`cmd/pixad/main.go`) and https://git.eeqj.de/sneak/pixa/issues/208
(`fetch-depth: 0` on the CI checkout, so the build sees the tags). Its rule
that no build stage runs `git describe` is not followed: pixa takes the
version from the `.git` in the build context, per
https://git.eeqj.de/sneak/pixa/issues/166, as the copy on `sneak/prompts`
`next` already says.
depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin `cmd/pixad/main.go`)
and https://git.eeqj.de/sneak/pixa/issues/208 (`fetch-depth: 0` on the CI
checkout, so the build sees the tags). Its rule that no build stage runs
`git describe` is not followed: pixa takes the version from the `.git` in the
build context, per https://git.eeqj.de/sneak/pixa/issues/166, as the copy on
`sneak/prompts` `next` already says.
- 2026-10-04 an integration test of the image proxy flow (closes #80):
`TestImageProxyFlow` in `internal/server` starts the database, handlers and
middleware from the constructors `pixad` uses, with a fresh state directory,
@@ -125,13 +133,12 @@ P2: security: per-IP rate limiting on the image routes
`httpfetcher.Config.DialContext` connects in place of the dialer that refuses
internal addresses, the URL and redirect checks still running, and
`handlers.Params.Fetcher` replaces the fetcher the handlers build.
- 2026-10-04 a URL made on the generator page with a `ttl` is tested to
expire (closes #199): a new test in `internal/handlers` makes a URL on the
generator page with a `ttl` of one second, checks that `/v1/e/` serves it at
once, waits two seconds and checks that it then answers 410. The test waits
for real, as pixa reads the clock directly when it makes and checks a URL; it
waits two seconds because the time a URL expires is kept in whole seconds.
Test only.
- 2026-10-04 a URL made on the generator page with a `ttl` is tested to expire
(closes #199): a new test in `internal/handlers` makes a URL on the generator
page with a `ttl` of one second, checks that `/v1/e/` serves it at once, waits
two seconds and checks that it then answers 410. The test waits for real, as
pixa reads the clock directly when it makes and checks a URL; it waits two
seconds because the time a URL expires is kept in whole seconds. Test only.
- 2026-10-04 referer blocklist (closes #90): `referer_blocklist`
(`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for
`allowlist_hosts` with the same matcher; an entry of either list that is
@@ -149,30 +156,29 @@ P2: security: per-IP rate limiting on the image routes
`README.md`, the comments in `internal/config/config.go` and the startup error
for the placeholder signing key name the new path; `scripts/manual-test.sh`
and its directory are deleted, as the handler tests in `internal/handlers`
cover every check it made except two: fetching a real image from the
internet, and a URL made on the generator page with a `ttl` answering 410 once
the `ttl` has passed (https://git.eeqj.de/sneak/pixa/issues/199);
`CONVENTIONS.md` is deleted, as `REPO_POLICIES.md` links the canonical Go HTTP
server conventions.
cover every check it made except two: fetching a real image from the internet,
and a URL made on the generator page with a `ttl` answering 410 once the `ttl`
has passed (https://git.eeqj.de/sneak/pixa/issues/199); `CONVENTIONS.md` is
deleted, as `REPO_POLICIES.md` links the canonical Go HTTP server conventions.
- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes
#198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write
that finds another in progress on another connection waits up to five seconds
for it, and the default `db_url` turns on WAL mode with
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
parameter the driver reads, so the database was never in WAL mode.
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup`
only passes through a periodic pass (closes #189): it slept for three
eviction intervals before writing its file, and a startup pass still running
then could adopt the file itself. It now holds the test database's only
connection until the startup pass waits for it after walking the empty
variant directory, writes the file and lets the connection go, as
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` only
passes through a periodic pass (closes #189): it slept for three eviction
intervals before writing its file, and a startup pass still running then could
adopt the file itself. It now holds the test database's only connection until
the startup pass waits for it after walking the empty variant directory,
writes the file and lets the connection go, as
`TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation
pass can adopt the file. Test only.
- 2026-10-04 logging in, logging out, the URL generator and `/v1/e/` have
handler tests (closes #77): new tests in `internal/handlers`, with no
network, check that `GET /` without a login session shows the login form; a
wrong key shows it again with an error and sets no session cookie; the right
key answers 303 to `/` with a session cookie marked `Secure`, `HttpOnly` and
handler tests (closes #77): new tests in `internal/handlers`, with no network,
check that `GET /` without a login session shows the login form; a wrong key
shows it again with an error and sets no session cookie; the right key answers
303 to `/` with a session cookie marked `Secure`, `HttpOnly` and
`SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout`
answers 303 to `/` with an empty session cookie sent with `Max-Age=0`;
`POST /generate` without a login session answers 303 to `/`; `/v1/e/` serves
@@ -182,11 +188,11 @@ P2: security: per-IP rate limiting on the image routes
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
branches that each add an entry at the top of Completed Steps merge without a
conflict and keep both entries. Git now never reports a conflict in
`TODO.md`: a real one keeps both versions of the lines, and two entries that
share an identical line can end up one inside the other, which a rebase can
do to an entry already on `next`. The Workflow above says to read the merged
entries after every merge or rebase.
conflict and keep both entries. Git now never reports a conflict in `TODO.md`:
a real one keeps both versions of the lines, and two entries that share an
identical line can end up one inside the other, which a rebase can do to an
entry already on `next`. The Workflow above says to read the merged entries
after every merge or rebase.
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
(closes #184): for an omitted key, the cache works out the limit when it
opens, after the database is open, as 75% of the sum of the free space on the
@@ -210,10 +216,10 @@ P2: security: per-IP rate limiting on the image routes
that pixa may not enter, aborts startup naming the file, as a file that does
not parse already did.
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
the deprecated `gomodguard` is switched off, so lint runs print no
deprecation warning; its successor `gomodguard_v2` runs with the shared
module block list, and `depguard` keeps `net/http/httptest` out of files that
are not tests. The tree needed no code changes.
the deprecated `gomodguard` is switched off, so lint runs print no deprecation
warning; its successor `gomodguard_v2` runs with the shared module block list,
and `depguard` keeps `net/http/httptest` out of files that are not tests. The
tree needed no code changes.
- 2026-10-04 the Content-Security-Policy allows no inline script or style
(closes #125): `script-src` and `style-src` are `'self'` only. The generator
page's two inline `onclick` handlers moved into
@@ -232,30 +238,30 @@ P2: security: per-IP rate limiting on the image routes
counts, the health check for a load balancer, what a stop does and its exit
codes, and what running outside Docker needs; `configs/Caddyfile` is the
example, checked with `caddy validate`.
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and
metrics recording have tests (closes #79): `MetricsAuth` on its own answers
401 with a challenge without credentials or with a wrong username or password
and lets the configured ones through; a preflight request gets `*` for any
origin when `access_control_allow_origin` is `*` and no
`Access-Control-Allow-Origin` from another origin than the configured one; a
`POST /` carrying the signing key leaves no trace of it in the request log
line, and the login handler's own log lines leave out the submitted key; the
metrics middleware on its own records a request it served, and the router
records nothing while no metrics username is set. Not tested: that the router
puts the basic auth in front of `/metrics` and records requests when a
metrics username is set. Only one test per package can set up `/metrics`, and
in `internal/server` that is `TestMaintenanceModeKeepsOtherRoutes`, which
needs the owner's approval to change; #180 holds it. Tests only; the basic
auth library already compares the password in constant time.
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and metrics
recording have tests (closes #79): `MetricsAuth` on its own answers 401 with a
challenge without credentials or with a wrong username or password and lets
the configured ones through; a preflight request gets `*` for any origin when
`access_control_allow_origin` is `*` and no `Access-Control-Allow-Origin` from
another origin than the configured one; a `POST /` carrying the signing key
leaves no trace of it in the request log line, and the login handler's own log
lines leave out the submitted key; the metrics middleware on its own records a
request it served, and the router records nothing while no metrics username is
set. Not tested: that the router puts the basic auth in front of `/metrics`
and records requests when a metrics username is set. Only one test per package
can set up `/metrics`, and in `internal/server` that is
`TestMaintenanceModeKeepsOtherRoutes`, which needs the owner's approval to
change; #180 holds it. Tests only; the basic auth library already compares the
password in constant time.
- 2026-10-04 the image route's signature check and error answers are tested
(closes #76): new tests in `internal/handlers`, with no network, check the
status and JSON error body for a missing, wrong, unpadded, upper-case or
expired signature on a host not on the allowlist, or a valid one sent for
its parent domain, a sibling host, a subdomain or the host with another
domain appended (401), an unparseable path (400), `localhost` as the
upstream host (403) and an upstream error (502); that an allowlisted host is
served without a signature, another host only with a valid one; and the
answers of `/robots.txt` and the health check. No code changes.
expired signature on a host not on the allowlist, or a valid one sent for its
parent domain, a sibling host, a subdomain or the host with another domain
appended (401), an unparseable path (400), `localhost` as the upstream host
(403) and an upstream error (502); that an allowlisted host is served without
a signature, another host only with a valid one; and the answers of
`/robots.txt` and the health check. No code changes.
- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates
(closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each
request an ID, its own `X-Request-ID` when that is at most 64 letters, digits,
@@ -272,26 +278,26 @@ P2: security: per-IP rate limiting on the image routes
`Vary: Accept` is left to #88.
- 2026-10-04 routes, encrypted URLs and config file documented (closes #75):
"Routes" in `README.md` lists every route with its method, purpose, what it
needs and the status codes it answers with, and says `q` and `fit` are part
of what is cached; "Encrypted URLs" covers logging in, making one on the
needs and the status codes it answers with, and says `q` and `fit` are part of
what is cached; "Encrypted URLs" covers logging in, making one on the
generator page, how long it lasts and the 410 once it has expired;
"Configuration" gives the order in which pixa looks for its config file;
`config.example.yml` lists `db_url` and `env` and gives every key's default;
`scripts/manual-test.sh` is left to #97.
- 2026-10-04 shutdown stops cache eviction in progress (closes #102):
`StartEviction` runs the eviction goroutine with its own context, which
`StopEviction` cancels, so a pass in progress stops at its next database
call, file, row or eviction candidate instead of running to completion, and
no pass starts after it, so a stop logs at most one warning;
`StopEviction` takes a context and, when that context ends before the
goroutine exits, stops waiting and returns its error; the handlers' stop hook
passes fx's stop context, so an eviction still running when fx's stop
deadline ends fails the stop and makes the exit code 1.
`StopEviction` cancels, so a pass in progress stops at its next database call,
file, row or eviction candidate instead of running to completion, and no pass
starts after it, so a stop logs at most one warning; `StopEviction` takes a
context and, when that context ends before the goroutine exits, stops waiting
and returns its error; the handlers' stop hook passes fx's stop context, so an
eviction still running when fx's stop deadline ends fails the stop and makes
the exit code 1.
- 2026-10-04 dead code in `internal/imgcache` is gone (closes #73): `Purge`,
which only returned an error and which nothing called, is no longer part of
the `ImageCache` interface or `Service`; the `SignatureValidator`,
`Allowlist` and `Storage` interfaces, which nothing implemented or used, are
deleted. Nothing else changes.
the `ImageCache` interface or `Service`; the `SignatureValidator`, `Allowlist`
and `Storage` interfaces, which nothing implemented or used, are deleted.
Nothing else changes.
- 2026-10-04 upstream host semaphores and variant `.meta` files no longer
outlive their use (closes #87): the fetcher counts the fetches holding or
waiting for a slot of each upstream host's semaphore and removes the host's
@@ -303,21 +309,20 @@ P2: security: per-IP rate limiting on the image routes
cache directories pixa uses (`cache/sources`, `cache/metadata`,
`cache/variants`) and how files are named in each, and the comments in
`001_schema.sql` name the same paths; the routes and the signature section
list the same output formats, `jpg` and `original` included; the TLS
sentence names `allow_http` as its exception; "Metrics" says only generic
HTTP and Go runtime metrics exist, measured and served only when the metrics
username and password are set.
- 2026-10-03 shutdown sets the exit code and waits for image processing
(closes #86): fx alone handles SIGINT and SIGTERM, and the server's own
signal handler is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's
code: 0 for a signal, 1 when the HTTP server cannot listen or the app fails
to start or to stop; the server's stop hook, which fx waits for, stops the
HTTP server, waits for the images still being processed, both within 5
seconds, then flushes Sentry; images still being processed after that are
logged with their count and make the exit code 1; a Sentry DSN that cannot be
used fails startup, so the stop hooks of what had already started run,
instead of exiting the process from a goroutine; the eviction loop is left to
#102.
list the same output formats, `jpg` and `original` included; the TLS sentence
names `allow_http` as its exception; "Metrics" says only generic HTTP and Go
runtime metrics exist, measured and served only when the metrics username and
password are set.
- 2026-10-03 shutdown sets the exit code and waits for image processing (closes
#86): fx alone handles SIGINT and SIGTERM, and the server's own signal handler
is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's code: 0 for a
signal, 1 when the HTTP server cannot listen or the app fails to start or to
stop; the server's stop hook, which fx waits for, stops the HTTP server, waits
for the images still being processed, both within 5 seconds, then flushes
Sentry; images still being processed after that are logged with their count
and make the exit code 1; a Sentry DSN that cannot be used fails startup, so
the stop hooks of what had already started run, instead of exiting the process
from a goroutine; the eviction loop is left to #102.
- 2026-10-03 every `script/cibuild` and `script/docker` run executes the checks
(closes #101): the `Dockerfile` declares `CHECK_EPOCH` above `make fmt-check`
and `make lint` in the lint stage and above `make test` in the build stage,
@@ -332,11 +337,11 @@ P2: security: per-IP rate limiting on the image routes
upstream fetch or cached source read and one transcode through
`golang.org/x/sync/singleflight`; the first request's processing ignores its
cancellation but keeps its deadline, and the others wait for its image or
error holding no upstream connection or processing slot, and stop waiting
when their own context ends; the request doing the processing waits for it
even then, up to its deadline; a request whose context has already ended
starts nothing; each request counts one miss, and the processing counts its
fetch and transcode once; a panic while processing is reported to Sentry when
error holding no upstream connection or processing slot, and stop waiting when
their own context ends; the request doing the processing waits for it even
then, up to its deadline; a request whose context has already ended starts
nothing; each request counts one miss, and the processing counts its fetch and
transcode once; a panic while processing is reported to Sentry when
`sentry_dsn` is set and becomes an error for every waiting request instead of
stopping pixad; documented in `README.md`.
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
@@ -345,20 +350,20 @@ P2: security: per-IP rate limiting on the image routes
still answers a preflight `OPTIONS` request; the login and URL generator
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
documented in `README.md` and `config.example.yml`.
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not
`dev` (closes #166): `.dockerignore` lets `.git` into the build context,
without `.git/config`; with no `VERSION` build argument the `Dockerfile`
takes the version from `git describe --tags --always`, and fails the build if
the context carries `.git` and no version comes out; `ARG VERSION` has no
default; pixad logs its version, with its name and architecture, as its first
log line at startup.
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
gives the directory and everything in it to `pixad` when the directory or one
of its top-level entries belongs to another user or group, sets its mode to
`750`, then runs the server as `pixad`; data left by an earlier run under
another uid is taken over this way; "Running under upaas" in `README.md` no
longer tells the operator to create or chown the host directory.
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not `dev`
(closes #166): `.dockerignore` lets `.git` into the build context, without
`.git/config`; with no `VERSION` build argument the `Dockerfile` takes the
version from `git describe --tags --always`, and fails the build if the
context carries `.git` and no version comes out; `ARG VERSION` has no default;
pixad logs its version, with its name and architecture, as its first log line
at startup.
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes #159):
`deploy/docker-entrypoint.sh` creates the directory if it is missing, gives
the directory and everything in it to `pixad` when the directory or one of its
top-level entries belongs to another user or group, sets its mode to `750`,
then runs the server as `pixad`; data left by an earlier run under another uid
is taken over this way; "Running under upaas" in `README.md` no longer tells
the operator to create or chown the host directory.
- 2026-09-29 variant content types kept in memory (closes #70):
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
@@ -397,8 +402,8 @@ P2: security: per-IP rate limiting on the image routes
`ARG VERSION` sits just above the build, so a new version reruns neither
`script/bootstrap` nor the tests.
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
migration files moved, contents unchanged, from `internal/database/schema/`
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
migration files moved, contents unchanged, from `internal/database/schema/` to
`internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
`internal/db/migrations` package embeds them and `internal/database` reads
them through its `FS()`; the `internal/database` package itself stays; the
version still comes from the filename prefix, so a database that has recorded
@@ -413,8 +418,8 @@ P2: security: per-IP rate limiting on the image routes
`=` padding kept, and gives the example's `sig` for a stated signing key.
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
is not owned on the host by a person's login account; the first-run step of
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad` is
not owned on the host by a person's login account; the first-run step of
"Running under upaas" in `README.md` names the uid and gid.
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
routes build `Cache-Control` from the request's `Expires`, which an encrypted
@@ -423,29 +428,27 @@ P2: security: per-IP rate limiting on the image routes
that is sooner, never negative; an allowlisted host's URL that has an `exp`
follows it too; `immutable` stays, as freshness now ends at the expiry;
documented in `README.md`.
- 2026-09-28 add the four settings `README.md` documented but pixa did not
have, which aborted startup as unknown keys (closes #61):
- 2026-09-28 add the four settings `README.md` documented but pixa did not have,
which aborted startup as unknown keys (closes #61):
`access_control_allow_origin` (default `*`, the CORS origin),
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
(default 50 MiB) and `downstream_timeout` (default `60s`, both the
server's write timeout and the per-request timeout); each has a
`PIXA_` variable; durations are positive Go duration strings, the size a
whole number of bytes up to 1 GiB, the origin `*` or one `http` or
`https` origin as `README.md` describes it; an invalid value
aborts startup naming the key and the value; documented in
`config.example.yml` and `README.md`.
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
counts the cached source images and processed variants (`source_content`
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
instead of reading `request_cache` and `output_content`, which nothing
writes; those two tables are left in the schema; a disabled disk cache
reports no items and no size. A hit is counted even when the request
context has ended. A miss is counted after it is served or fails, also
when the request context has ended by then, with the bytes it read from
upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move,
including for an upstream body that fails partway or a fetched source
that then fails the magic byte check; `transform_count` counts each image
the image processor transcodes.
(default 50 MiB) and `downstream_timeout` (default `60s`, both the server's
write timeout and the per-request timeout); each has a `PIXA_` variable;
durations are positive Go duration strings, the size a whole number of bytes
up to 1 GiB, the origin `*` or one `http` or `https` origin as `README.md`
describes it; an invalid value aborts startup naming the key and the value;
documented in `config.example.yml` and `README.md`.
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` counts
the cached source images and processed variants (`source_content` plus
`variant_content`) and takes their size from `Cache.UsageBytes`, instead of
reading `request_cache` and `output_content`, which nothing writes; those two
tables are left in the schema; a disabled disk cache reports no items and no
size. A hit is counted even when the request context has ended. A miss is
counted after it is served or fails, also when the request context has ended
by then, with the bytes it read from upstream, so `upstream_fetch_count` and
`upstream_fetch_bytes` move, including for an upstream body that fails partway
or a fetched source that then fails the magic byte check; `transform_count`
counts each image the image processor transcodes.
- 2026-09-28 strip metadata from processed images (closes #82): every output is
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
profile; the image is first turned upright with `AutoRotate` (before sizes are
@@ -456,220 +459,201 @@ P2: security: per-IP rate limiting on the image routes
attempts per minute per client address, and an attempt over the limit is
refused with 429 and a `Retry-After` header; the address is the one
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
counted by its /64, and an IPv4-mapped address as the IPv4 address it
carries; the limit is a `RateLimit` middleware in `internal/middleware` on
counted by its /64, and an IPv4-mapped address as the IPv4 address it carries;
the limit is a `RateLimit` middleware in `internal/middleware` on
`github.com/go-chi/httprate`, which the image routes can reuse; the library
keeps counts for the current and the previous minute only; documented in
`README.md`.
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
cache errors (closes #72): an `exp` in the URL that is not a whole
number, an empty `exp=` included, is a 400 naming `exp` and the value,
instead of being ignored and answered with 401 as if the URL had no
`exp`; only an `exp` missing from the URL is unchanged; `README.md` says
so where it documents `exp`. A failed variant `.meta` write, source
metadata JSON write, `Stats` count query, stats counter update, negative
cache write or expired negative cache delete is now logged at `warn`
with the path or key and the error, and stays non-fatal.
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a
`fit` in the URL with an empty value (`fit=`) is a 400 naming `fit`,
instead of being served as `cover` and verified against a signature
made for `cover`; only a `fit` missing from the URL is still `cover`;
any other value still goes through the existing fit-mode check;
`README.md` says so where it documents `fit`.
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q`
that is not a whole number from 1 to 100, an empty `q` included, is a
400 naming `q` and the value, instead of being served at the default
85; the route reads `q` with the generator's quality check
(`parseFormInt` with `minQuality` and `maxQuality`); only a `q` missing
from the URL is still 85; a query string that cannot be decoded, such
as `q=80%`, is a 400 showing it; any query parameter given more than
once (`q`, `fit`, `sig`, `exp` alike) is a 400 naming it, so none is
read from its first value only; `README.md` states the range and both
query-string rules.
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes
#133): a variable whose name starts with `PIXA_` but is neither a
setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as
an unknown config key does, and `PIXA_PORT` is named with a pointer to
`PORT`; the check runs after the config file loads, so the variables
the file's `env:` section sets are checked too; documented in
`README.md`.
- 2026-09-28 start on a fresh upaas volume (closes #129): the image
starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
does not own it (`deploy/docker-entrypoint.sh`), then runs the server
as `pixad` through `su-exec`, so a root-owned host directory
bind-mounted there no longer stops the container at startup;
`README.md` gains a "Running under upaas" section.
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` +
`script/lint` (closes #104): `make lint` calls `script/lint`, the only
way the linter is run; inside a container (both Dockerfiles set
`container=docker`) it runs `golangci-lint`, anywhere else it builds the
hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again;
the `Dockerfile` lint stage runs `make lint`; no host or nix-shell
`golangci-lint` path remains (`script/bootstrap` installs no linter);
a per-run `CACHEBUST` build-arg keeps the lint step from being served
from cache, and a tmpfs mount on that step keeps Go's and
golangci-lint's caches out of its layer, so a run leaves no large build
cache behind; `golangci-lint config verify` stays out, as it fetches its
schema over an unpinned live HTTPS call
- 2026-09-28 every setting as an environment variable (closes #128, also
covers #99): each config key can be set by `PIXA_` plus the key in upper
case (`.` written as `_`), and the port by `PORT`; a variable present in
the environment, even empty, wins over the config file, which wins over
the default; the typed getters read the variable first, so every existing
check applies to it and a bad value aborts startup naming the variable;
lists are comma-separated, and an empty variable (or `""` in the file) is
an empty list; the Docker image no longer bakes in `config.docker.yml` or
passes `--config`, and its `HEALTHCHECK` probes `PORT` (default `8080`);
the config file is looked for under `/etc/pixa` and `~/.config/pixa`
instead of the daemon name `pixad`; documented in `README.md` and
`config.example.yml`.
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed
data is now `host:path:query:width:height:format:expiration:quality:fit`,
using `85` and `cover` when the URL has no `q` or `fit`, so one signed
URL can no longer be replayed across other quality and fit values to
create unauthorized cache entries and transcodes; the known-answer
vectors in `internal/signature/golden_test.go` and the README signature
specification describe the new format.
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
the runtime stage probing `/.well-known/healthcheck.json` with busybox
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
starts it with a throwaway `PIXA_SIGNING_KEY`, and passes only once
Docker reports it healthy within 30 seconds, removing the container on
exit; the Gitea workflow runs it after `script/cibuild`.
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed cache
errors (closes #72): an `exp` in the URL that is not a whole number, an empty
`exp=` included, is a 400 naming `exp` and the value, instead of being ignored
and answered with 401 as if the URL had no `exp`; only an `exp` missing from
the URL is unchanged; `README.md` says so where it documents `exp`. A failed
variant `.meta` write, source metadata JSON write, `Stats` count query, stats
counter update, negative cache write or expired negative cache delete is now
logged at `warn` with the path or key and the error, and stays non-fatal.
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a `fit` in the
URL with an empty value (`fit=`) is a 400 naming `fit`, instead of being
served as `cover` and verified against a signature made for `cover`; only a
`fit` missing from the URL is still `cover`; any other value still goes
through the existing fit-mode check; `README.md` says so where it documents
`fit`.
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q` that is
not a whole number from 1 to 100, an empty `q` included, is a 400 naming `q`
and the value, instead of being served at the default 85; the route reads `q`
with the generator's quality check (`parseFormInt` with `minQuality` and
`maxQuality`); only a `q` missing from the URL is still 85; a query string
that cannot be decoded, such as `q=80%`, is a 400 showing it; any query
parameter given more than once (`q`, `fit`, `sig`, `exp` alike) is a 400
naming it, so none is read from its first value only; `README.md` states the
range and both query-string rules.
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes #133):
a variable whose name starts with `PIXA_` but is neither a setting's variable
nor `PIXA_CONFIG_PATH` aborts startup naming it, as an unknown config key
does, and `PIXA_PORT` is named with a pointer to `PORT`; the check runs after
the config file loads, so the variables the file's `env:` section sets are
checked too; documented in `README.md`.
- 2026-09-28 start on a fresh upaas volume (closes #129): the image starts as
root only to give `/var/lib/pixa` to `pixad` when `pixad` does not own it
(`deploy/docker-entrypoint.sh`), then runs the server as `pixad` through
`su-exec`, so a root-owned host directory bind-mounted there no longer stops
the container at startup; `README.md` gains a "Running under upaas" section.
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + `script/lint`
(closes #104): `make lint` calls `script/lint`, the only way the linter is
run; inside a container (both Dockerfiles set `container=docker`) it runs
`golangci-lint`, anywhere else it builds the hash-pinned `Dockerfile.lint`,
whose last step runs `script/lint` again; the `Dockerfile` lint stage runs
`make lint`; no host or nix-shell `golangci-lint` path remains
(`script/bootstrap` installs no linter); a per-run `CACHEBUST` build-arg keeps
the lint step from being served from cache, and a tmpfs mount on that step
keeps Go's and golangci-lint's caches out of its layer, so a run leaves no
large build cache behind; `golangci-lint config verify` stays out, as it
fetches its schema over an unpinned live HTTPS call
- 2026-09-28 every setting as an environment variable (closes #128, also covers
#99): each config key can be set by `PIXA_` plus the key in upper case (`.`
written as `_`), and the port by `PORT`; a variable present in the
environment, even empty, wins over the config file, which wins over the
default; the typed getters read the variable first, so every existing check
applies to it and a bad value aborts startup naming the variable; lists are
comma-separated, and an empty variable (or `""` in the file) is an empty list;
the Docker image no longer bakes in `config.docker.yml` or passes `--config`,
and its `HEALTHCHECK` probes `PORT` (default `8080`); the config file is
looked for under `/etc/pixa` and `~/.config/pixa` instead of the daemon name
`pixad`; documented in `README.md` and `config.example.yml`.
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed data
is now `host:path:query:width:height:format:expiration:quality:fit`, using
`85` and `cover` when the URL has no `q` or `fit`, so one signed URL can no
longer be replayed across other quality and fit values to create unauthorized
cache entries and transcodes; the known-answer vectors in
`internal/signature/golden_test.go` and the README signature specification
describe the new format.
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in the
runtime stage probing `/.well-known/healthcheck.json` with busybox `wget`;
`script/docker-smoke` (`make docker-smoke`) builds the image, starts it with a
throwaway `PIXA_SIGNING_KEY`, and passes only once Docker reports it healthy
within 30 seconds, removing the container on exit; the Gitea workflow runs it
after `script/cibuild`.
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
`net/netip` list parser as `blocked_networks` (an invalid entry aborts
startup naming the key and value; an omitted key defaults to the RFC 1918
private ranges, an explicitly empty list trusts no one, and an explicit
list replaces the default); a new
`internal/clientip` package resolves the client address by honoring
`X-Forwarded-For` only when the direct peer is a trusted proxy, walking
the chain right-to-left to the rightmost non-proxy entry, so a client
connecting directly cannot spoof its address; the resolved address is
stored in the request context by a new middleware and used by the
request-logging middleware and the login-attempt logs in place of the
raw peer address; documented in `README.md` and `config.example.yml`.
`net/netip` list parser as `blocked_networks` (an invalid entry aborts startup
naming the key and value; an omitted key defaults to the RFC 1918 private
ranges, an explicitly empty list trusts no one, and an explicit list replaces
the default); a new `internal/clientip` package resolves the client address by
honoring `X-Forwarded-For` only when the direct peer is a trusted proxy,
walking the chain right-to-left to the rightmost non-proxy entry, so a client
connecting directly cannot spoof its address; the resolved address is stored
in the request context by a new middleware and used by the request-logging
middleware and the login-attempt logs in place of the raw peer address;
documented in `README.md` and `config.example.yml`.
- 2026-09-21 blocked networks configuration extending SSRF protection: a
`blocked_networks` config key taking a list of CIDRs (parsed with
`net/netip`, an invalid entry aborts startup naming the key and value),
added to the built-in blocklist rather than replacing it; the built-in
ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments
`192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96`
(IPv4-mapped forms covered); enforcement stays in the dial-time
re-resolution so the DNS-rebinding window remains closed; documented in
`README.md` and `config.example.yml`.
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
route and the token generator (closes #62): `imgcache.ValidateDimension`
alone holds the `MaxDimension` bound and is used by the path parser, by
the new `ValidateImageRequest` (which also applies `ValidateFitMode`)
and by the generator; both the `/v1/image/` and `/v1/e/` routes call
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a
400 rather than an out-of-memory or a 500 from the processor; the URL
generator answers 400 naming the field for a `width` or `height` that is
not a number or fails the shared check, a `quality` that is not a number
from 1 to 100, a `ttl` that is not a number from 0 to the largest number
of seconds the expiry calculation can hold, or an unknown `fit`; an empty
`quality` is 85 and
an empty `ttl` never expires; the form's width and height inputs stop at
8192
- 2026-09-21 http.Server hardening (closes #92): added
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
existing timeouts and wired them onto the server; added a `LimitBody`
middleware capping the two form POST bodies (`POST /`, `POST /generate`)
at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF
middleware so an oversized body is refused as 413 rather than being read
as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (v2 schema, `default: all` minus six disabled
linters, `lll` 88, tests included): bumped the pinned
`golangci/golangci-lint:v2.12.2-alpine` image in `Dockerfile` and the
release-archive sha256 pins in `script/bootstrap`; fixed the findings
the stricter config surfaced (notably `paralleltest`, `wsl_v5`,
`blocked_networks` config key taking a list of CIDRs (parsed with `net/netip`,
an invalid entry aborts startup naming the key and value), added to the
built-in blocklist rather than replacing it; the built-in ranges extended to
CGNAT `100.64.0.0/10`, IETF protocol assignments `192.0.0.0/24`, benchmark
`198.18.0.0/15`, and NAT64 `64:ff9b::/96` (IPv4-mapped forms covered);
enforcement stays in the dial-time re-resolution so the DNS-rebinding window
remains closed; documented in `README.md` and `config.example.yml`.
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL route and the
token generator (closes #62): `imgcache.ValidateDimension` alone holds the
`MaxDimension` bound and is used by the path parser, by the new
`ValidateImageRequest` (which also applies `ValidateFitMode`) and by the
generator; both the `/v1/image/` and `/v1/e/` routes call
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a 400
rather than an out-of-memory or a 500 from the processor; the URL generator
answers 400 naming the field for a `width` or `height` that is not a number or
fails the shared check, a `quality` that is not a number from 1 to 100, a
`ttl` that is not a number from 0 to the largest number of seconds the expiry
calculation can hold, or an unknown `fit`; an empty `quality` is 85 and an
empty `ttl` never expires; the form's width and height inputs stop at 8192
- 2026-09-21 http.Server hardening (closes #92): added `HTTPReadHeaderTimeout`
(10s, bounds the slowloris header dribble) and `HTTPIdleTimeout` (120s, bounds
keep-alive reuse) alongside the existing timeouts and wired them onto the
server; added a `LimitBody` middleware capping the two form POST bodies
(`POST /`, `POST /generate`) at `MaxFormBytes` (1 MiB) and returning 413,
applied ahead of the CSRF middleware so an oversized body is refused as 413
rather than being read as a missing CSRF token (403); left `WriteTimeout` at
60s unchanged
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical `.golangci.yml`
(v2 schema, `default: all` minus six disabled linters, `lll` 88, tests
included): bumped the pinned `golangci/golangci-lint:v2.12.2-alpine` image in
`Dockerfile` and the release-archive sha256 pins in `script/bootstrap`; fixed
the findings the stricter config surfaced (notably `paralleltest`, `wsl_v5`,
`goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`, `testpackage` —
white-box test files renamed to `*_internal_test.go`), including #55's
code absorbed after it merged, iterating the pinned linter to
`0 issues.`; no single finding total is substantiable, since
golangci-lint's `uniq-by-line` reveals new findings on a line as
others there are fixed — the documented re-measurements were 81 after
the #53 merge and 149 after the #55 merge; three behavior changes, so
not a pure no-op: `Cache.StoreVariant` now takes a `context.Context`
(`noctx`), so a cancelled request skips its best-effort accounting
row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and
leaked `.tmp-*.json` on failure, now fixed with explicit removals; and
the `signing_key` validation error text gained `value too short: `;
the eviction loop's uncancellable context is deferred to #102 under a
`//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep
the snake_case JSON wire/disk formats unchanged; `make check` green
- 2026-08-07 implement cache size management and eviction (closes
#51): new `cache_max_bytes` config key validated by the startup
framework (explicit values used exactly with no floor, `0` disables
the disk cache entirely, omitted defaults to max(75% of free space
on the filesystem containing `<state_dir>/cache/`, 500 MiB), logged
at startup); processed variants are now tracked in the database (a
new `variant_content` table and an LRU timestamp on `source_content`)
so total usage is two SUMs, never a directory scan on the hot path; a
background goroutine evicts globally least-recently-used entries
(variants and source blobs merged) to the limit, woken by a periodic
ticker and by write-pressure notifications from stores; a source
blob and ALL of its `source_metadata` references are deleted in one
transaction before the file is unlinked, so multi-referenced blobs
are never removed while referenced and rows never point at deleted
files; a startup and periodic reconciliation pass adopts untracked
variant files, drops rows for missing files, removes unreachable
source blobs, and sweeps stale temp files
- 2026-08-07 validate configuration on startup, fail fast on bad
config (closes #52): a config value that is set but unparseable or
invalid aborts startup naming the key and value (defaults apply only
to omitted keys), unknown config keys abort startup, a malformed
config file aborts instead of being skipped, and `state_dir` is
verified creatable and writable before the listener binds
- 2026-08-07 manual test pass of the auth and encrypted URL flows
against a locally built and running `pixad` (built from `main` at
`6573b9d`, port 18099, local throwaway config); all six checks
passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
page with `name="key"` password form
- [x] wrong key shows an error: POST `/` with `key=wrong-key`
returned HTTP 200 login page containing "Invalid signing key"
- [x] correct signing key shows the generator form: POST `/`
returned HTTP 303 to `/` with
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
GET `/` with that cookie rendered `Pixa - URL Generator` with the
`/generate` form and logout link
white-box test files renamed to `*_internal_test.go`), including #55's code
absorbed after it merged, iterating the pinned linter to `0 issues.`; no
single finding total is substantiable, since golangci-lint's `uniq-by-line`
reveals new findings on a line as others there are fixed — the documented
re-measurements were 81 after the #53 merge and 149 after the #55 merge; three
behavior changes, so not a pure no-op: `Cache.StoreVariant` now takes a
`context.Context` (`noctx`), so a cancelled request skips its best-effort
accounting row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and
leaked `.tmp-*.json` on failure, now fixed with explicit removals; and the
`signing_key` validation error text gained `value too short: `; the eviction
loop's uncancellable context is deferred to #102 under a
`//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep the
snake_case JSON wire/disk formats unchanged; `make check` green
- 2026-08-07 implement cache size management and eviction (closes #51): new
`cache_max_bytes` config key validated by the startup framework (explicit
values used exactly with no floor, `0` disables the disk cache entirely,
omitted defaults to max(75% of free space on the filesystem containing
`<state_dir>/cache/`, 500 MiB), logged at startup); processed variants are now
tracked in the database (a new `variant_content` table and an LRU timestamp on
`source_content`) so total usage is two SUMs, never a directory scan on the
hot path; a background goroutine evicts globally least-recently-used entries
(variants and source blobs merged) to the limit, woken by a periodic ticker
and by write-pressure notifications from stores; a source blob and ALL of its
`source_metadata` references are deleted in one transaction before the file is
unlinked, so multi-referenced blobs are never removed while referenced and
rows never point at deleted files; a startup and periodic reconciliation pass
adopts untracked variant files, drops rows for missing files, removes
unreachable source blobs, and sweeps stale temp files
- 2026-08-07 validate configuration on startup, fail fast on bad config (closes
#52): a config value that is set but unparseable or invalid aborts startup
naming the key and value (defaults apply only to omitted keys), unknown config
keys abort startup, a malformed config file aborts instead of being skipped,
and `state_dir` is verified creatable and writable before the listener binds
- 2026-08-07 manual test pass of the auth and encrypted URL flows against a
locally built and running `pixad` (built from `main` at `6573b9d`, port 18099,
local throwaway config); all six checks passed, plus all nine tests in
`scripts/manual-test.sh` (closes #49):
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login` page with
`name="key"` password form
- [x] wrong key shows an error: POST `/` with `key=wrong-key` returned HTTP
200 login page containing "Invalid signing key"
- [x] correct signing key shows the generator form: POST `/` returned HTTP
303 to `/` with
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`; GET
`/` with that cookie rendered `Pixa - URL Generator` with the
`/generate` form and logout link
- [x] a generated encrypted URL serves the image: POST `/generate`
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
61706 bytes
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
after 3 s returned HTTP 410 Gone with
`{"error":"URL has expired","status":410,...}`
- [x] logout redirects back to login: GET `/logout` returned HTTP
303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
subsequent GET `/` rendered the login form again
- 2026-08-07 fix the two remaining gosec findings (G124 in
internal/session): session cookies now always carry
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
`make check` green (closes #47)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned HTTP
200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of 61706
bytes
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched after 3 s
returned HTTP 410 Gone with
`{"error":"URL has expired","status":410,...}`
- [x] logout redirects back to login: GET `/logout` returned HTTP 303 to `/`
with `Set-Cookie: pixa_session=; Max-Age=0`; subsequent GET `/`
rendered the login form again
- 2026-08-07 fix the two remaining gosec findings (G124 in internal/session):
session cookies now always carry Secure/HttpOnly/SameSite=Strict on both the
set and clear paths; `make check` green (closes #47)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-04-07 extract magic byte detection into internal/magic (#42)
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
- 2026-03-20 enforce and document exact-match-only signature
verification (#40)
- 2026-03-20 bound imageprocessor.Process input read to prevent
unbounded memory use (#37); consolidate appname into an
internal/globals constant (#34)
- 2026-03-20 enforce and document exact-match-only signature verification (#40)
- 2026-03-20 bound imageprocessor.Process input read to prevent unbounded memory
use (#37); consolidate appname into an internal/globals constant (#34)
- 2026-03-18 parse version prefix from migration filenames (#33)
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
faster CI (#23)
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for faster CI
(#23)
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
golangci-lint and gosec fixes of that date (#14); arm64 Docker build
fix (#16)
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0
image processing items, now done)
golangci-lint and gosec fixes of that date (#14); arm64 Docker build fix (#16)
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 image
processing items, now done)
# Future Steps