check / check (push) Successful in 11s
upaas bind-mounts an existing host directory and sets no container user, so a directory made with mkdir as root left pixad unable to write /var/lib/pixa, and the container exited at startup. The image now starts as root: deploy/docker-entrypoint.sh gives /var/lib/pixa to pixad when pixad does not own it, then runs the server as pixad through su-exec (alpine's package), so the server never runs as root. README.md gains a "Running under upaas" section: port, volume, environment variables, health check, first-run step. Model: opus-5-5
223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# Workflow
|
|
|
|
* branch per issue from `next`
|
|
* do the work in Next Step
|
|
* move Next Step to the top of Completed Steps
|
|
* move the top item of Future Steps into Next Step
|
|
* commit (`TODO.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
|
|
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
|
|
|
|
# Next Step
|
|
|
|
P1: rate limit global concurrent upstream fetches to prevent resource
|
|
exhaustion
|
|
|
|
# Completed Steps
|
|
|
|
- 2026-09-28 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`.
|
|
- 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 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
|
|
- [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
|
|
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
|
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
|
|
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
|
|
- 2026-03-20 enforce and document exact-match-only signature
|
|
verification (#40)
|
|
- 2026-03-20 bound imageprocessor.Process input read to prevent
|
|
unbounded memory use (#37); consolidate appname into an
|
|
internal/globals constant (#34)
|
|
- 2026-03-18 parse version prefix from migration filenames (#33)
|
|
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
|
|
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
|
|
faster CI (#23)
|
|
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
|
|
golangci-lint and gosec fixes of that date (#14); arm64 Docker build
|
|
fix (#16)
|
|
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0
|
|
image processing items, now done)
|
|
|
|
# Future Steps
|
|
|
|
- P1: strip EXIF and other metadata from processed images (privacy)
|
|
- P2: security
|
|
- referer blacklist
|
|
- per-IP rate limiting
|
|
- per-origin rate limiting
|
|
- P2: HTTP response handling
|
|
- Last-Modified headers
|
|
- Vary header for content negotiation
|
|
- X-Request-ID propagation
|
|
- P2: auto format selection (format=auto based on Accept header)
|
|
- P2: configuration
|
|
- add all configuration options from README
|
|
- YAML config file support
|
|
- P2: operational
|
|
- optional Sentry error reporting
|
|
- comprehensive request logging
|
|
- Prometheus performance metrics
|
|
- integration tests for the image proxy flow
|
|
- load tests to verify the 1k to 5k req/s target
|
|
- P2: documentation
|
|
- configuration options
|
|
- API endpoints
|
|
- deployment guide
|
|
- example nginx or caddy reverse proxy config
|