Author SHA1 Message Date
sneak 0e9949afbc Test blocked_networks on both forms of an IPv6 source host holding IPv4
check / check (push) Canceled after 0s
Both image routes: [::203.0.113.5] must answer 403 when blocked_networks
lists ::/96, and [::ffff:203.0.113.5] when it lists ::ffff:0:0/96 or
203.0.113.0/24. The ::/96 and ::ffff:0:0/96 cases fail on this branch:
isBlockedIP matches such an address only by the IPv4 address it holds.

Model: opus-5-5
2026-10-16 04:06:52 +00:00
sneak 294474a2cd Refuse local-use NAT64 addresses, and IPv4-compatible ones in blocked_networks (closes #299)
check / check (push) Canceled after 0s
64:ff9b:1::/48 joins the built-in blocked ranges next to 64:ff9b::/96.
isBlockedIP now reads an address in ::/96 as the IPv4 address in its
last four bytes before matching blocked_networks, as it already unmapped
the ::ffff: form. The reading isPrivateIP gained for ::/96 moved into
ipv4CompatibleToIPv4, which both call.

Model: opus-5-5
2026-10-16 03:43:55 +00:00
sneak 04d9a6bf37 Test the local-use NAT64 range and blocked_networks on IPv6 source hosts
check / check (push) Canceled after 0s
Both image routes, with the fetcher pixad builds from the config:
[64:ff9b:1::a00:1] must answer 403, and [::203.0.113.5] must answer 403
when blocked_networks lists 203.0.113.0/24 and must not be refused when
the list is empty. The first two fail on next: both addresses are
fetched.

Model: opus-5-5
2026-10-16 03:40:53 +00:00
154 changed files with 10209 additions and 9294 deletions
+1
View File
@@ -18,3 +18,4 @@ jobs:
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild
- run: script/docker-smoke
+1 -5
View File
@@ -14,11 +14,7 @@ These rules MUST be followed at all times, it is very important.
without asking first. In almost all cases, the code should be changed, NOT the
tests. If you think the test needs to be changed, make your case for that and
ask for permission to proceed, then stop. You need explicit user approval to
modify existing tests. A bug fix may change an existing test without asking
when that test pins exactly the behavior the filed bug calls wrong, and the
change moves it to the fixed behavior and nothing else; any other change to an
existing test still needs explicit user approval. (You do not need user
approval for writing NEW tests.)
modify existing tests. (You do not need user approval for writing NEW tests.)
- When linting, assume the linter config is CORRECT, and that each item output
by the linter is something that legitimately needs fixing in the code.
+22 -31
View File
@@ -1,16 +1,12 @@
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# Every stage declares ARG CACHEBUST first: script/ passes a new value
# on every build, which reruns every RUN step after it. The steps that
# compile mount Go's build cache, which survives from build to build.
# Lint phase. script/lint builds it alone. The linter is run directly:
# `make lint` and script/lint are themselves a docker build.
# golangci/golangci-lint:v2.14.0, 2026-10-15
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
ARG CACHEBUST
# The linter compiles every package, and govips needs the libvips
# headers for that. REPO_POLICIES.md has the lint phase install them
@@ -22,29 +18,25 @@ RUN apt-get update \
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Test phase. script/test builds it alone. -race needs cgo and so a C
# compiler, which the Debian Go image ships and the alpine one does not.
# golang:1.25.4-trixie, 2026-10-15
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
# govips compiles against the libvips and libheif headers, and the tests
# need libvips' JPEG XL support; libvips-dev brings all three. The tests
# also encode AVIF, which needs libheif's AVIF encoder,
# libheif-plugin-aomenc: Debian only recommends it, so it is named here.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev libheif-plugin-aomenc \
&& rm -rf /var/lib/apt/lists/*
ARG CACHEBUST
WORKDIR /src
# script/bootstrap --cgo installs the build dependencies (the libvips
# and libheif headers, and libvips' JPEG XL support, which the tests
# need) and downloads the Go modules.
COPY script/ ./script/
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
RUN script/bootstrap --cgo
COPY . .
# Without -v first; on a failure, again with -v for the details, and
@@ -60,23 +52,22 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
# Build stage. Nothing is wanted from the two phases above: these copies
# make BuildKit build them first, so nothing after them runs unless lint
# and test passed.
# make BuildKit build them first, so this stage runs only when lint and
# test passed.
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# git for the version, and for govips a C compiler, pkg-config and the
# libvips and libheif headers
RUN apk add --no-cache git build-base pkgconfig vips-dev libheif-dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src
# Build dependencies, git among them, and Go modules, as in the test
# phase
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap --cgo
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
@@ -111,6 +102,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# the libvips and musl it was built with.
# alpine:3.22, 2026-10-08
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
ARG CACHEBUST
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
# support, without which pixad does not start.
@@ -121,7 +113,6 @@ RUN apk add --no-cache \
ca-certificates \
tzdata \
su-exec
ARG CACHEBUST
# Copy binary from builder
COPY --from=builder /pixad /usr/local/bin/pixad
+1 -7
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest bench
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION)
@@ -64,12 +64,6 @@ docker-smoke:
loadtest:
@script/loadtest
# Serve cache hits in parallel through the image handler and report hits per
# second (needs Go on the host after `script/bootstrap --cgo`, not Docker; a
# benchmark, not part of check)
bench:
@script/bench
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
docker-versioned:
@script/docker
+105 -218
View File
@@ -76,7 +76,8 @@ database and the disk cache:
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
must be able to write the directory.
- `cache_max_bytes` limits the source and transformed images together. The
database and files still being written come on top, and eviction runs in the
database, the metadata files, the `.meta` file beside each transformed image
and files still being written come on top, and eviction runs in the
background, so the cache can pass the limit for a while: leave room on the
volume beyond it.
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
@@ -88,25 +89,21 @@ A load balancer's health check can request `/.well-known/healthcheck.json`,
which answers 200 whenever pixa is running, in maintenance mode too (see
`maintenance_mode`).
On SIGTERM or SIGINT pixa stops accepting connections. The requests in progress
and the images being processed get 5 seconds to finish. A request not finished
by then is cut off. pixa then writes the waiting last-used times of cached
images to the database (see Storage) and exits. It exits with 1 when:
- images were still being processed after those 5 seconds,
- some of those times could not be written, or
- another part of pixa failed to stop.
Otherwise it exits with 0. `docker stop` waits 10 seconds before it kills the
container.
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
progress and the images being processed 5 seconds to finish, then writes to the
database the counts of cache hits, misses, fetches and conversions that requests
could not write by their deadline, and exits: with 0, or with 1 when images were
still being processed after those 5 seconds, some of those counts could not be
written, or another part of pixa failed to stop. A request not finished by then
is cut off. `docker stop` waits 10 seconds before it kills the container.
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
support, which on Alpine is the `vips-jxl` package and which the nix and brew
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
on. Building pixa also needs the development files of libvips and libheif,
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these;
the `Dockerfile` installs its own packages. Plain `script/bootstrap`, which
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these,
as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but
none of the C libraries: the checks compile pixa in Docker. Docker itself must
@@ -180,46 +177,34 @@ allowlisted source hosts for open access.
### Storage
- **Source images**:
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of host, path and query>`, one
file for each source URL
- **Source content**:
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
- **Source metadata**:
`<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json` (host,
path and query, content hash, upstream status and headers, fetch time)
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
- **Transformed images**:
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`,
each with a `.meta` file beside it holding its content type
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
name.
Each source image and each transformed image has one row in the database,
holding its size and when it was last used. Two source URLs that serve the same
bytes are stored twice, once under each URL. A source image is stored by writing
its file and then its row; when the row cannot be written, the file is removed
again.
Multiple source paths may reference the same content blob; the database tracks
references rather than using filesystem refcounting.
Source and transformed image files are written under a temporary name, synced to
disk, and only then renamed into place, so a crash leaves no file under its own
name with part of its data. When pixa starts, and every five minutes after, it
removes each cached source or transformed image whose file is empty, or not the
size the database records for it, with a warning naming the file; the next
request for it fetches the source or converts the image again. Looking for those
files reads the database once for every cached file, so pixa skips each
five-minute look that would start less than ten times as long after the last
look ended as that look took. After a look that took one minute, the next one
starts 10 to 15 minutes after that look ended.
Source and transformed image files, and the `.meta` file beside each transformed
image, are written under a temporary name, synced to disk, and only then renamed
into place, so a crash leaves no file under its own name with part of its data.
When pixa starts, and every five minutes after, it removes each cached source or
transformed image whose file is empty, or not the size the database records for
it, with a warning naming the file; the next request for it fetches the source
or converts the image again.
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
Test). Toward it, a cache hit opens the transformed image's file and nothing
else, and makes no database call. The content type comes from the file's first
bytes, which are those of the format pixa wrote it in; a file whose first bytes
are not those of a format pixa writes is not served, and the image is converted
again. A hit hands the time it was served to a background writer and does not
wait for the writer. Eviction orders by that time. The hit is counted in memory.
A miss that uses a cached source image also hands that writer the time it used
the image. Once a second that writer writes the waiting times in one
transaction. While 10,000 times of transformed images, or 10,000 of source
images, wait to be written, a further one is dropped, and that image only looks
less recently used to eviction. `script/bench` measures how many hits pixa
serves per second (see Load Test).
Test). Toward it, pixa keeps in memory the content types of the 10,000
transformed images most recently cached or served, so a cache hit on one of them
reads only the image file from disk and not the metadata file stored beside it.
### Routes
@@ -248,58 +233,30 @@ path under `/v1/` answers 200, in maintenance mode too.
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
and converted (below). Needs: a signature, unless the host is allowlisted (see
Source Hosts). Answers:
- 200 with the image.
- 304 when `If-None-Match` matches the image's `ETag`.
- 400 for a URL or parameter that is not valid.
- 400 when the format is `auto` and the `Accept` header is not valid.
- 406 when the format is `auto` and `Accept` allows none of the formats it
chooses from.
- 401 for a missing or wrong signature.
- 401 for a missing `exp`.
- 401 for an `exp` in the past.
- 403 when the request's `Referer` names a host in `referer_blocklist`,
checked before the signature, the cache and the upstream fetch.
- 403 when the upstream host, or a host it redirects to, is `localhost` or
ends in `.localhost` or `.local`.
- 403 when the upstream host, or a host it redirects to, has an address in a
blocked network (see `blocked_networks`).
- 429 over `image_requests_per_minute` (below).
- 415 when the upstream answers with a `Content-Type` that is not one of the
source formats (below).
- 415 when the upstream sends a body whose first bytes are not those of the
format its `Content-Type` names.
- 415 when the upstream sends an image whose first bytes libvips reads as
another format.
- 502 when the upstream answers with an error status.
- 502 for 5 minutes after that for the same source URL, when pixa has
neither the image nor its source image in its cache.
- 502 likewise for 5 minutes after a body whose first bytes are not those of
its `Content-Type`.
- 503 when pixa is busy.
- 503 when pixa is in maintenance mode.
- 504 when the upstream has not sent its response headers within
`upstream_fetch_timeout`.
- 500 when that time runs out while the image itself is still arriving.
- 500 for any other failure.
Source Hosts). Answers: 200; 304 when `If-None-Match` matches the image's
`ETag`; 400 for a URL or parameter that is not valid, or for the format `auto`
an `Accept` header that is not valid; 406 for the format `auto` when `Accept`
allows none of the formats it chooses from; 401 for a missing or wrong
signature, a missing `exp` or an `exp` in the past; 403 when the request's
`Referer` names a host in `referer_blocklist`, checked before the signature,
the cache and the upstream fetch; 403 when the upstream host, or a host it
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
address in a blocked network (see `blocked_networks`); 429 over
`image_requests_per_minute` (below); 415 when the upstream answers with a
`Content-Type` that is not one of the source formats (below), or with an image
whose first bytes libvips reads as another format; 502 when the upstream
answered with an error status, and for 5 minutes after that for an image of
the same source URL that pixa can neither serve from its cache nor make from
the source image still in its cache; 503 when pixa is busy or in maintenance
mode; 500 for any other failure.
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
(see Encrypted URLs). Needs: nothing but the URL. Answers:
- 200 with the image.
- 304 when `If-None-Match` matches the image's `ETag`.
- 400 for a token that does not decrypt.
- 400 for a token that asks for a size or fit that is not valid.
- 410 once the token has expired.
- 400 when the token's format is `auto` and the `Accept` header is not
valid.
- These answers, for the same causes as on `/v1/image/`:
- 406
- 403
- 415
- 429
- 502
- 503
- 504
- 500
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
decrypt, or that asks for a size or fit that is not valid; 410 once it has
expired; 504 when the upstream has not sent its response headers within
`upstream_fetch_timeout`, but 500 when that time runs out while the image
itself is still arriving; 400 for an `Accept` header that is not valid, and
406, 403, 415, 429, 502, 503 and 500, as for `/v1/image/`.
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
nothing. Answers: 200.
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
@@ -394,22 +351,11 @@ either side may be; so is a size with `fit=outside`, which scales the image to
cover the size and so past it on one side, when that side would come out
above 8192.
An AVIF image of more than 16,777,216 pixels, the number 4096x4096 and 8192x2048
have, is refused with 400 before anything is decoded, as its encode could take
more than 40 seconds (see below). That includes `orig` of an AVIF source, and
`auto` when it chooses AVIF, so a client whose `Accept` names `image/avif` but
not `image/jxl` is refused such a size.
The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
`Content-Type` must name its format, and the image's first bytes must match it.
An upstream answering with any other `Content-Type`, SVG included, is refused
with 415. So is a body whose first bytes are not those of the format its
`Content-Type` names, such as a JPEG sent as `image/png`, text sent as an image,
or an empty body. pixa then does not fetch that source URL again for 5 minutes,
as after an upstream error status, and meanwhile answers 502 for an image that
would need that fetch. An image whose first bytes libvips reads as another
format, such as an SVG whose first bytes pass the check for AVIF, is refused
with 415 too.
with 415, and so is an image whose first bytes libvips reads as another format,
such as an SVG whose first bytes pass the check for AVIF.
With the format `auto`, pixa chooses the format for each request from its
`Accept` header, in this order:
@@ -454,18 +400,14 @@ transcode that stops this way is answered 503 with the error
ended. govips gives pixa no way to stop libvips once an encode has begun, so an
encode keeps its slot until it finishes, which can be after
`downstream_timeout`. The limit of 8192 on each side of the source and of the
output, and for AVIF the limit of 16,777,216 pixels, bound how long that takes.
With one libvips thread, as pixad runs it, encoding 8192x8192 random pixels
takes up to about 1 second as JPEG, 15 as PNG and 16 as GIF, the slowest of 8 or
16 bits per sample, with or without an alpha channel. The WebP and JPEG XL
encoders run below libvips' default effort, and AVIF at the lowest effort govips
can set, so that no encode takes more than 40 seconds of the default
`downstream_timeout` of 60: at any quality and 8 or 16 bits per sample,
8192x8192 random pixels with an alpha channel take up to about 23 seconds as
WebP and 27 as JPEG XL, and 4096x4096, 8192x2048 or 2048x8192 up to about 34 as
AVIF, which at quality 85 would take about 96 at 8192x8192. Without an alpha
channel they take up to about 16, 13 and 22 seconds. Decoding such a source adds
up to about 11 seconds.
output bounds how long that takes. With one libvips thread, as pixad runs it,
encoding 8192x8192 random pixels takes up to about 1 second as JPEG, 15 as PNG,
16 as GIF, 63 as WebP, 110 as AVIF and 96 as JPEG XL, the slowest of 8 or 16
bits per sample, with or without an alpha channel. WebP, AVIF and JPEG XL are
that slow only with an alpha channel: 8-bit RGB takes about 44, 53 and 28
seconds. Decoding such a source adds up to about 11 seconds. A WebP, AVIF or
JPEG XL encode can therefore hold its slot past the default `downstream_timeout`
of 60 seconds.
The login form (`POST /`) is limited to 5 attempts per minute per client
address, counting an IPv6 client by its /64; an attempt over the limit is
@@ -517,13 +459,13 @@ nor change what it asks for.
sent with an empty format, or none, also makes a JPEG XL URL. Width and
height both empty or `0` keep the original size; if only one of them is empty
or `0`, that side is scaled to keep the image's proportions. A URL whose
scaled side would come out above 8192 answers 400, and so does an AVIF URL of
more than 16,777,216 pixels or a source URL whose path holds a `..` segment.
So does a source URL that the image URL would fetch written another way, and
the page then shows the URL pixa would fetch: one with a user name and
password, which pixa does not send, or one whose path holds `%2F`, fetched as
`/`, or `(`, fetched as `%28`, as pixa keeps the source path decoded and
fetches it percent-encoded again (see Routes).
scaled side would come out above 8192 answers 400, and so does a source URL
whose path holds a `..` segment. So does a source URL that the image URL
would fetch written another way, and the page then shows the URL pixa would
fetch: one with a user name and password, which pixa does not send, or one
whose path holds `%2F`, fetched as `/`, or `(`, fetched as `%28`, as pixa
keeps the source path decoded and fetches it percent-encoded again (see
Routes).
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
it expires. `<host>` is the host the page was opened on, and the URL starts
with `http` instead while `debug` is on. The name after the token is ignored
@@ -752,12 +694,7 @@ Key settings in more detail:
and generator pages. Default: empty
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
benchmark, NAT64, and the like); an invalid CIDR aborts startup. An IPv6
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
address it holds, so an IPv6 range such as `::/0` refuses neither it nor a
plain IPv4 address. An entry written in that form is read as the IPv4 range it
covers, so `::ffff:10.0.0.0/104` refuses what `10.0.0.0/8` refuses; such an
entry shorter than `/96` covers more than IPv4 addresses and aborts startup
benchmark, NAT64, and the like); an invalid CIDR aborts startup
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
of these ranges; the logged and login-recorded client address is then the
@@ -767,22 +704,16 @@ Key settings in more detail:
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a
private network; an explicitly empty list (`[]`) trusts no one, and an
explicit list replaces the default. An invalid CIDR aborts startup. An IPv6
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
address it holds, so an IPv6 range such as `::/0` trusts neither it nor a
plain IPv4 address. An entry written in that form is read as the IPv4 range it
covers, so `::ffff:10.0.0.0/104` trusts what `10.0.0.0/8` trusts; such an
entry shorter than `/96` covers more than IPv4 addresses and aborts startup.
Set this to the address pixa sees for requests that come through your proxy,
such as `172.17.0.1/32`, when the defaults do not cover it, or to trust
nothing else (see the login limit and `image_requests_per_minute` under
Routes). For a proxy on the Docker host that connects to pixa over
`127.0.0.1`, that address is the gateway of the container's Docker network
(`172.17.0.1` on the default bridge), not the proxy's own address; a proxy
that connects through another of the host's addresses is seen with that
address. To be sure which address it is, set this to `[]` (or
`PIXA_TRUSTED_PROXIES` to empty), send a request through the proxy, and read
`remoteIP` in pixa's request log line for it
explicit list replaces the default. An invalid CIDR aborts startup. Set this
to the address pixa sees for requests that come through your proxy, such as
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
(see the login limit and `image_requests_per_minute` under Routes). For a
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
is the gateway of the container's Docker network (`172.17.0.1` on the default
bridge), not the proxy's own address; a proxy that connects through another of
the host's addresses is seen with that address. To be sure which address it
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
through the proxy, and read `remoteIP` in pixa's request log line for it
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
as a duration such as `30s` (the default) or `2m`
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
@@ -791,17 +722,7 @@ Key settings in more detail:
duration; default `60s`. The upstream fetch counts toward it, and so do the
waits for an upstream connection and for a processing slot (up to 10 seconds
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
- `signing_key` — the secret that protects signed URLs, encrypted URLs and
logins. pixa uses it as it is for two things:
- the HMAC key of signed URLs (see Signature Specification)
- the password of the login page
pixa derives every other key it uses from it at startup, with HKDF-SHA256:
- a key that signs the session cookie
- a key that encrypts the session cookie
- a key that encrypts the token of an encrypted URL
- a key that signs the CSRF token cookie of the login and generator pages
- `signing_key` — HMAC secret for URL signatures
- `db_url` — the SQLite database to open; omitted, it is
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
database in WAL mode. `state_dir` is percent-encoded in it, so a `%`, `?` or
@@ -848,34 +769,20 @@ See `configs/config.example.yml` for all options with defaults.
### Architecture
- **Dependency injection**: Uber fx. `internal/app` builds every part of pixad
from the config in one fx graph. Each setting's default is declared once, in
`internal/config`.
- **Dependency injection**: Uber fx
- **HTTP router**: go-chi
- **Image processing**: govips (CGO wrapper for libvips)
- **Database**: SQLite via modernc.org/sqlite
- **Static assets**: embedded via `//go:embed`
- **Metrics**: Prometheus, at `/metrics`. pixa serves `/metrics` and measures
HTTP requests only when `metrics.username` and `metrics.password` are set.
`/metrics` holds:
- the duration of HTTP requests
- the size of HTTP responses
- the HTTP requests in flight
- the Go runtime and process metrics
- `pixa_cache_hits_total`: image requests served from the cache
- `pixa_cache_misses_total`: image requests not served from the cache
- `pixa_upstream_fetches_total`: fetches of a source image that read any
bytes
- `pixa_upstream_fetch_bytes_total`: the bytes those fetches read
- `pixa_transforms_total`: images converted
A request's `handler` label is the pattern of the route it matches, such as
`/v1/image/*`, never its path. Requests that match no route share the label
`unmatched`: those the router answers 404 or 405, and CORS preflights. A
request's `method` label is its method when that is `GET`, `HEAD`, `POST`,
`PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT` or `TRACE`, and `other` for
any other method.
- **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics
(duration, response size, requests in flight) and the Go runtime and process
metrics; requests are measured and `/metrics` is served only when
`metrics.username` and `metrics.password` are set. A request's `handler` label
is the pattern of the route it matches, such as `/v1/image/*`, never its path.
Requests that match no route share the label `unmatched`: those the router
answers 404 or 405, and CORS preflights. A request's `method` label is its
method when that is `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`,
`OPTIONS`, `CONNECT` or `TRACE`, and `other` for any other method
- **Logging**: stdlib slog
## Entrypoints
@@ -907,9 +814,6 @@ provide:
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a
benchmark, not part of `script/check` (see Load Test)
- `script/bench` — run the Go benchmark that serves cache hits in parallel
through the image handler, with Go on the host after `script/bootstrap --cgo`;
a benchmark, not part of `script/check` (see Load Test)
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`),
then `script/check`, then build the image as `script/docker` does
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
@@ -918,25 +822,19 @@ provide:
`script/precommit`
Every `docker build` in these scripts passes a new value of the `CACHEBUST`
build argument, which every stage of the `Dockerfile` declares, so every `RUN`
step after that line, the lint and test steps included, runs on every build
instead of coming from the build cache. Above the line are the steps that
install system packages and download the Go modules; Docker keeps their layers
until the base image, the step itself, `go.mod` or `go.sum` changes. Go's build
cache is kept from build to build: the steps that compile mount it, so a package
whose inputs are unchanged is not compiled again, and both `go test` runs pass
`-count=1` so that no stored test result is replayed. The lint step also mounts
golangci-lint's cache, which reuses a package's findings only when its files,
those of the packages it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged. The `test`
phase runs on the Debian Go image, with Debian's libvips and libheif; the image
pixa ships is built and runs on Alpine. `script/check`, `script/cibuild`,
`script/docker`, `script/lint`, `script/test`, `script/setup` and
`script/install-precommit` are the standard copies from `sneak/prompts`, kept
identical to them. `script/fmt` and `script/fmt-check` are the standard copies
with pixa's `gofmt` step kept before prettier. prettier formats the markdown
only, not the HTML templates, as it cannot parse a Go template action inside a
tag.
build argument, which every stage of the `Dockerfile` declares first, so every
`RUN` step, the lint and test phases included, runs on every build instead of
coming from the build cache. Go's build cache is kept from build to build: the
steps that compile mount it, so a package whose inputs are unchanged is not
compiled again, and both `go test` runs pass `-count=1` so that no stored test
result is replayed. The `test` phase runs on the Debian Go image, with Debian's
libvips and libheif; the image pixa ships is built and runs on Alpine.
`script/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`,
`script/setup` and `script/install-precommit` are the standard copies from
`sneak/prompts`, kept identical to them. `script/fmt` and `script/fmt-check` are
the standard copies with pixa's `gofmt` step kept before prettier. prettier
formats the markdown only, not the HTML templates, as it cannot parse a Go
template action inside a tag.
## Load Test
@@ -990,17 +888,6 @@ The numbers depend on the machine and on whatever else runs on it. The first
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
nothing about the target.
`script/bench` (or `make bench`) measures cache hits alone, without the network
and with no pixad process: a Go benchmark in `internal/handlers` serves one
cached image through the `/v1/image/` handler from as many goroutines at once as
the machine has CPUs, and reports the hits served per second as `hits/s`. Each
run starts with an empty cache and an empty database, a SQLite file of its own,
and puts the image in the cache before it measures. The benchmark fails if
serving the hits ran any statement on the database. Like the load test it is not
part of `script/check`. It runs Go on the host, as the load test does, and
compiles pixa there, so it needs the C compiler and libraries that
`script/bootstrap --cgo` installs, but not Docker.
## TODO
See [TODO.md](TODO.md) for the full prioritized task list.
+34 -67
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-18
last_modified: 2026-10-15
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -159,11 +159,8 @@ style conventions are in separate documents:
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own `TMPDIR`, so the lock is
out of reach. The lint phase does keep golangci-lint's cache between check
builds (below), but it lints every checkout at `/src`, and a finding is
taken from that cache only for the same files, settings and golangci-lint
version.
org, in both directions. A container has its own golangci-lint cache, its
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
@@ -173,21 +170,15 @@ style conventions are in separate documents:
id, so no two builds share a value, not even the three in one
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
`script/docker` are the four, and there is no fifth — `script/check` runs the
two gate phases and `script/fmt-check`, and builds no image of its own. In
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
that install system packages and download Go's modules, or right after `FROM`
in a stage with neither, and every step that lints, tests or compiles comes
after it. A step that installs anything else as well, such as a
`script/bootstrap` that also installs node packages, comes after it too. A new
value reruns every `RUN` step after that line; `COPY` steps are compared by
content and need nothing. Docker keeps the layers above the line until the
base image, their own lines or the files they copy change. A stage without the
`ARG` line is served from cache on an unchanged tree, so a repository
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
not pass `--no-cache`: it reruns every step too, but it also builds the layers
above the line again and empties every cache mount, so Go's build cache and
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
survive between check builds, would start empty every time. A bare
two gate phases and `script/fmt-check`, and builds no image of its own. Every
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
are compared by content and need nothing. A stage without the `ARG` line is
served from cache on an unchanged tree, so a repository re-vendors the scripts
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
it reruns every step too, but it also empties every cache mount, and Go's
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
packages survive between check builds, would start empty every time. A bare
`docker build .` is not evidence that anything ran: a sub-second build
reporting success is a cache hit, not a result. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
@@ -203,25 +194,19 @@ style conventions are in separate documents:
`Dockerfile`:
```dockerfile
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# Every stage declares ARG CACHEBUST first: script/ passes a new value
# on every build, which reruns every RUN step after it. The steps that
# compile mount Go's build cache, which survives from build to build.
# Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
ARG CACHEBUST
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
@@ -229,10 +214,10 @@ style conventions are in separate documents:
# cache also holds test results, which go test would otherwise replay.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
ARG CACHEBUST
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -cover ./... || \
@@ -240,19 +225,19 @@ style conventions are in separate documents:
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so nothing after them can
# run unless lint and test passed.
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The VERSION build arg when one is given, otherwise
@@ -289,26 +274,16 @@ style conventions are in separate documents:
- Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- Check builds keep these, and none of them can hand back a stale result:
- The layers above `ARG CACHEBUST`: the system packages and Go's
modules. Docker installs the packages again when the base image or the
install line changes, and downloads the modules again when `go.mod` or
`go.sum` changes. Go checks every module against `go.sum` as it
downloads it, and no later build can change a kept layer. The modules
are kept in a layer rather than a cache mount because a cache mount is
shared by every build on the host, and Go builds from whatever files
it holds without checking them again (only `go mod verify` does), so a
file changed there would go unnoticed.
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
root), mounted on the steps that compile: `golangci-lint run`, both
`go test` runs and `go build`. Go reuses a compiled package only when
all its inputs are unchanged. The cache also holds test results, which
is why both `go test` runs carry `-count=1`.
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
a package's findings only when the package's files and those of every
package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged.
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
it for root), and only on the steps that compile: `golangci-lint run`,
both `go test` runs and `go build`. It keeps compiled packages between
check builds, and Go reuses one only when all its inputs are unchanged, so
it saves the compile without skipping a check. Nothing else is kept:
golangci-lint's own cache would replay stored findings in place of a lint
run, and Go builds from whatever files a kept module cache holds without
checking them again (only `go mod verify` does), so a file changed there
would go unnoticed. Go's build cache also holds test results, which is why
both `go test` runs carry `-count=1`.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
@@ -317,20 +292,12 @@ style conventions are in separate documents:
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them. The test phase,
on the Debian Go image, installs the libraries its tests need the same
way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so
Docker keeps it until the base image or the install line changes:
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
FROM golangci/golangci-lint@sha256:... AS lint
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
+7 -240
View File
@@ -30,248 +30,15 @@ P2: security: per-origin rate limiting
# Completed Steps
- 2026-10-26 `internal/imgcache` holds the cache only (closes #317). The new
package `internal/imageproxy` fetches, transforms, caches and serves images.
It imports `imgcache`, and `imgcache` does not import it.
- `imgcache.Service` is `imageproxy.Proxy`, built by `imageproxy.NewProxy`
from an `imageproxy.Config`.
- `ImageResponse`, `CacheStatus`, `ErrNegativeCached` and
`ErrProcessingTimedOut` moved with it, under their names.
- `imgcache.CacheConfig` is `imgcache.Config`.
- The cache has one read and one write for each kind it keeps. Each takes
the place of an earlier method:
- `Variant`, of `GetVariant`
- `PutVariant`, of `StoreVariant`
- `Source`, of `LookupSource` followed by `GetSourceContent`
- `PutSource`, of `StoreSource`
- `Failure`, of `checkNegativeCache`
- `PutFailure`, of `StoreNegative`
- `Variant` and `Source` each open the file and hand its access time to the
background writer. A cache hit still makes no database call.
- `imgcache.go` and `module.go` are gone.
- `NewProxy` makes the counters of upstream fetches and transforms, and
registers them on the registry it is given. The cache keeps only the hit
and miss counters.
- A cached source whose row cannot be read is logged as "failed to load
cached source, fetching". It was logged as "source lookup failed".
- The proxy's tests moved to `internal/imageproxy`. They reach the cache
through its database, its state directory and its metrics registry.
- Those tests run the reconciliation pass as pixad does when it starts: with
a second cache over the same database and directory.
- 2026-10-26 both image routes serve an image with one function, `serveImage`,
and answer its errors with one, `respondImageError` (closes #316).
`HandleImage` parses the path and verifies the signature, and `HandleImageEnc`
decrypts the token. Each then calls `serveImage`.
- `handleImageError`, the encrypted route's own mapping, is gone.
- `/v1/image/` answers 504 when the upstream has not sent its response
headers within `upstream_fetch_timeout`, as `/v1/e/` did. It answered 500.
- `respondImageError` logs the image service's errors as "failed to get
image". Each answer gets the same level on `/v1/image/` and on `/v1/e/`:
- 500: error, as pixa's own failure
- 400: warn
- 403: warn
- 415: warn
- 502: warn
- 503: warn
- 504: warn
- Before, `/v1/image/` logged all of these answers at error level. `/v1/e/`
logged only its 500, as "image request failed", without the host and the
path.
- `respondImageError` does not log the 415 for a body refused for its first
bytes, on either route. The image service logs it as "source refused", at
warn level.
- The "image served" line carries the host, the path and `fetched_bytes` on
both routes. The debug line "encrypted image request" is gone.
- The debug line "failed to decrypt URL" is now "invalid encrypted URL". A
token that decrypts to a payload that does not decode also gets it.
- A failed write is logged once, as "failed to write response" at warn
level, since most often the client has gone away. It carries the
`request_id`, the host, the path, `served_bytes` and the error, and is not
also logged as "image served".
- 2026-10-26 every key derived from the signing key is derived in one place
(closes #315): `seal.DeriveKeys` returns them as one `seal.Keys`, with their
salts declared beside it. `internal/providers` calls it once at startup. Each
key is derived as before, with `seal.DeriveKey` and the same salt.
- The session manager, the encrypted URL generator and the CSRF protection
each take their own key instead of the signing key.
- `session.NewManager`, `encurl.NewGenerator`, `newCSRFProtect` and
`handlers.New` return no error, as nothing in them can fail now.
- The signing key itself stays the HMAC key of signed URLs and the login
password.
- `README.md` says under `signing_key` what it protects.
- 2026-10-26 the image request holds only what names the cached object (closes
#314): its source and its transform, which the variant key covers. The signed
URL format, the signing algorithm and every route's answers are unchanged.
- The `/v1/image/` handler checks `allowlist_hosts` and verifies the
signature itself, with `Signer.Verify(req, sig, expires)`.
- Each image handler keeps its URL's expiry as a value of its own, for
`Cache-Control`. `encurl`'s `Generate` takes the expiry and its `Parse`
returns it.
- The fetcher takes the source and builds its URL with `Source.URL`, over
http only when the fetcher's own `AllowHTTP` is set. `validateURL` takes
that URL, so no URL string is built and parsed again.
- `chooseAutoFormat` returns the format it chooses, and the handler puts it
into the request it parsed.
- The image service serves images through `Get`, and has `WaitForProcessing`
for shutdown.
- Removed:
- `Signature`, `Expires`, `AllowHTTP` and `SourceURL` of the image
request
- `Service.ValidateRequest`, and the service's signer, allowlist and
`AllowHTTP`
- `extractHost` of the fetcher and `urlToFSPath` of the mock fetcher
- 2026-10-26 pixad's fx graph builds the image pipeline (closes #313).
`database.New` now opens the database, so the parts below can read it while
they are built; its stop hook still runs after the cache's. The cache's
`MaxBytes` is the one value for the disk cache limit, and 0 turns the disk
cache off. For an omitted `cache_max_bytes`, `internal/providers` works out
`config.DefaultCacheMaxBytes` before it builds the cache. `internal/app` and
the server tests build the parts below with `providers.Constructors()`; the
image proxy test replaces only the fetcher's dial function.
- Built by the providers in `internal/providers`, and given to
`handlers.New`:
- the cache
- the fetcher
- the processor
- the image service
- the session manager
- the encrypted URL generator
- Removed:
- `initImageService` and `handlers.Params.Fetcher`
- `CacheConfig.UseDefaultMaxBytes`, `CacheConfig.DisableDiskCache` and
`Cache.disabled`
- `httpfetcher.DefaultConfig()` and the fetcher's copies of setting
defaults
- `imageprocessor.DefaultMaxInputBytes`, and the processor's defaults
for a zero `MaxInputBytes` or `MaxConcurrentProcessing`
- 2026-10-26 the code nothing in production called is gone (closes #312). The
handlers tests sign their image URLs with `generateSignedURL`, a test helper
that writes them as the removed generator did. Removed:
- `Service.Warm` and the `ImageCache` interface
- `Service.GenerateSignedURL` and `Signer.GenerateSignedURL`
- `signature.ParseParams`
- `imagerequest.ParseImagePath`
- `PeekAndValidate`, `IsSupportedMIMEType`, `ImageFormatToMIME` and
`FormatOriginal` of `internal/magic`
- `SupportedInputFormats` and `SupportedOutputFormats` of the image
processor
- `IsEmpty` and `Count` of the allowlist
- `ssrfSafeDialer`
- `CacheStale`, `ErrCacheMiss`, `ErrNegativeCache` and
`ImageResponse.LastModified`
- `ContentStorage.Load` and `VariantStorage.Load`
- 2026-10-26 cache hits, misses, upstream fetches, the bytes those fetches read
and transforms are counted as Prometheus counters in memory (closes #311).
They are `pixa_cache_hits_total`, `pixa_cache_misses_total`,
`pixa_upstream_fetches_total`, `pixa_upstream_fetch_bytes_total` and
`pixa_transforms_total`, fields of the cache. `NewCache` makes them and
registers them on the registry it is given (`internal/imgcache/counters.go`).
pixad gives it Prometheus' default registry, which `/metrics` serves; each
test gives it a new one. The `Increment` methods add to their cache's
counters. `cache_stats`, `Cache.Stats`, `Service.Stats`, `CacheStats` and the
pending counts are gone. The background goroutine writes only access times
now: `StartPendingAccessTimeWrites` starts it and
`StopPendingAccessTimeWrites` stops it, in `pendingaccesstimes.go`.
- 2026-10-21 the schema file declares only the tables and columns pixa writes
and reads (closes #310): `output_content` and `request_cache` are gone, and so
are the columns nothing read: the host, path, query, `content_type` and
`fetched_at` of `source_content`, the `content_type` and `created_at` of
`variant_content`, and the `status_code`, `error_message` and `fetched_at` of
`negative_cache`. `StoreSource` no longer takes the fetch result,
`LookupSource` returns only the source key, `StoreVariant` no longer takes a
content type, `StoreNegative` no longer takes a status code or message, and
reconciliation adopts a variant file without reading its first bytes.
- 2026-10-21 a cache hit makes no database call (closes #309): `Service.Get`
opens the variant file with `Cache.GetVariant` and nothing else, with no
existence check first. The content type comes from the file's first bytes; a
file whose first bytes are not those of an output format, SVG included, is a
miss, and the variant is made again. A hit hands its access time to a channel
that holds up to 10,000 and drops the rest, and its hit count to the pending
counts, without waiting; every count is now a pending count. A miss that finds
a cached source hands that source's access time to a second such channel. The
goroutine `StartPendingCountWrites` starts writes, once a second, the access
times in one transaction and the counts in one `UPDATE`;
`StopPendingCountWrites` writes what is left once at shutdown and logs
separately the access times and the counts it could not write.
`checkNegativeCache` no longer deletes an expired row; `CleanExpired` in the
eviction goroutine does. The `.meta` files, `VariantMeta`, `LoadWithMeta`,
`variantContentTypeFromSidecar`, `metaCache`, `Lookup` and `golang-lru` are
gone. `make bench` (`script/bench`) runs `BenchmarkHandleImage_CacheHits` with
Go on the host after `script/bootstrap --cgo`, outside `make check`.
- 2026-10-21 a source is stored the way a variant is (closes #308): one
`source_content` row and one file per source URL, the file named by the
source's `SourceKey`, the SHA-256 of its host, path and query made as the
variant key is, under `cache/sources/`. A store writes the file, then the row,
and removes the file when the row cannot be written; an eviction deletes the
row, then the file, with no lock and no transaction. `source_metadata`, the
JSON files under `cache/metadata/`, `MetadataStorage` and the content lock are
gone. Two source URLs that serve the same bytes now store them twice.
- 2026-10-21 one image request type instead of five (closes #307): the new
package `internal/imagerequest`, which imports no other package of pixa, holds
`imagerequest.Request` with its two parts, `Source` (host, path and query) and
`Transform` (size, format, quality and fit), the `Format` and `Fit` types, the
checks, the parsing of the `/v1/image/` path and the variant key.
`imgcache.ParsedURL`, `imgcache.ImageRequest`, `signature.Request`,
`imageprocessor.Request`, the second `Size`, format and fit types and the
conversions between them are gone. The image processor takes a `Transform`
only, and `encurl` keeps a private struct with the token's short CBOR field
names, so tokens made before still decode. Every URL of both image routes
answers as before.
- 2026-10-21 the Gitea workflow checks out the repo and runs `script/cibuild`,
nothing else (closes #203): its `script/docker-smoke` step is removed, as the
canonical `REPO_POLICIES.md` says, since `make check` covers what CI needs to
check. CI no longer starts the image and waits for its healthcheck.
`script/docker-smoke` and `make docker-smoke` stay, to run by hand.
- 2026-10-20 a WebP, AVIF or JPEG XL encode at the limits takes at most about 34
seconds (closes #272): with one libvips thread, 8192x8192 random pixels with
an alpha channel took about 61 seconds to encode as WebP, 81 as JPEG XL and,
at quality 85, 96 as AVIF, so with the default `downstream_timeout` of 60
seconds such an image was never delivered, yet its encode held a processing
slot to the end. WebP is now saved at effort 2 and JPEG XL at effort 4, which
take up to about 23 and 27 seconds at any quality. AVIF was already at the
lowest effort govips can set, so an AVIF output of more than 16,777,216 pixels
is refused with 400 before anything is decoded; 4096x4096, 8192x2048 or
2048x8192 takes up to about 34 seconds at any quality. Resizing with
`fit=cover` already decodes the input, so the output's dimensions are worked
out before resizing. `README.md` gives the limit and the new times.
- 2026-10-20 a source whose first bytes do not match its `Content-Type` answers
415 (closes #253): a body whose first bytes are not those of the format its
`Content-Type` names, such as a JPEG sent as `image/png`, text, or a body
under 12 bytes, answered 500 on both image routes and was logged as an error
of pixa's, and each request for it fetched the whole body again. Both routes
now answer 415, and the image service logs it at warning level as a refused
source. It also gets a negative cache entry, as an upstream error status does,
so the source URL is not fetched again for 5 minutes, and meanwhile an image
that would need that fetch answers 502.
- 2026-10-20 `AGENTS.md`: a bug fix may change an existing test without asking
when that test pins exactly the behavior the filed bug calls wrong, and the
change moves it to the fixed behavior and nothing else (closes #280).
- 2026-10-18 re-vendored from `sneak/prompts` commit `0653579` (closes #221):
each `Dockerfile` stage installs its system packages and downloads the Go
modules above `ARG CACHEBUST`, so check builds keep them, and the lint step
keeps golangci-lint's cache.
- 2026-10-19 a `trusted_proxies` entry in the IPv4-mapped form trusted nothing;
it is now read as the IPv4 range it covers by the same code that reads
`blocked_networks`, and such an entry shorter than `/96` aborts startup
(closes #303).
- 2026-10-18 the next reconciliation pass waits at least ten times as long as
the last one took, so passes on a large cache no longer run back to back
(closes #250).
- 2026-10-18 a `blocked_networks` entry in the IPv4-mapped form refused nothing;
`::ffff:10.0.0.0/104` is now read as `10.0.0.0/8`, and such an entry shorter
than `/96` aborts startup (closes #301).
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
addresses refuses the local-use NAT64 range (`64:ff9b:1::/48`, RFC 8215) as it
refuses `64:ff9b::/96`, and refuses an IPv6 address in the IPv4-compatible
form (`::/96`) when `blocked_networks` contains either the address as written
or the IPv4 address it holds (closes #299). A source host such as
`[64:ff9b:1::a00:1]` was fetched, which on a network that runs NAT64 on that
range reaches the IPv4 address it holds, here `10.0.0.1`; and with
`203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was refused but
`[::203.0.113.5]` was fetched. Both now answer 403. The mapped form
(`::ffff:a.b.c.d`) is still matched only as the IPv4 address it holds: Go
keeps a plain IPv4 address in that form, and an IPv6 entry such as `::/0` must
not refuse every IPv4 address.
refuses `64:ff9b::/96`, and matches an IPv6 address in the IPv4-compatible
form (`::/96`) against `blocked_networks` by the IPv4 address it holds, as it
already matched the mapped form (`::ffff:a.b.c.d`) (closes #299). A source
host such as `[64:ff9b:1::a00:1]` was fetched, which on a network that runs
NAT64 on that range reaches the IPv4 address it holds, here `10.0.0.1`; and
with `203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was
refused but `[::203.0.113.5]` was fetched. Both now answer 403.
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
addresses judges an IPv6 address in the IPv4-compatible form (`::/96`), such
as `::127.0.0.1` or `::10.0.0.1`, by the IPv4 address in its last four bytes
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/go-chi/httprate v0.16.0
github.com/gorilla/csrf v1.7.3
github.com/gorilla/securecookie v1.1.2
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/prometheus/client_golang v1.23.2
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/cobra v1.10.2
+2
View File
@@ -228,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+10
View File
@@ -72,3 +72,13 @@ func (w *HostAllowList) IsAllowed(u *url.URL) bool {
return false
}
// IsEmpty returns true if the allow list has no entries.
func (w *HostAllowList) IsEmpty() bool {
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
}
// Count returns the total number of allow list entries.
func (w *HostAllowList) Count() int {
return len(w.exactHosts) + len(w.suffixHosts)
}
+84
View File
@@ -139,3 +139,87 @@ func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
},
})
}
func TestHostAllowList_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct {
name string
patterns []string
want bool
}{
{
name: "empty",
patterns: []string{},
want: true,
},
{
name: "nil",
patterns: nil,
want: true,
},
{
name: "whitespace only",
patterns: []string{" ", ""},
want: true,
},
{
name: "has entries",
patterns: []string{"example.com"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns)
if got := w.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
func TestHostAllowList_Count(t *testing.T) {
t.Parallel()
tests := []struct {
name string
patterns []string
want int
}{
{
name: "empty",
patterns: []string{},
want: 0,
},
{
name: "exact hosts only",
patterns: []string{"a.com", "b.com", "c.com"},
want: 3,
},
{
name: "suffix hosts only",
patterns: []string{".a.com", ".b.com"},
want: 2,
},
{
name: "mixed",
patterns: []string{"exact.com", ".suffix.com"},
want: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns)
if got := w.Count(); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want)
}
})
}
}
-5
View File
@@ -7,7 +7,6 @@ import (
"os/signal"
"syscall"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/cobra"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
@@ -17,7 +16,6 @@ import (
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/middleware"
"sneak.berlin/go/pixa/internal/providers"
"sneak.berlin/go/pixa/internal/server"
)
@@ -63,10 +61,7 @@ func run(_ *cobra.Command, _ []string) {
server.New,
middleware.New,
healthcheck.New,
// The registry /metrics serves
func() prometheus.Registerer { return prometheus.DefaultRegisterer },
),
fx.Provide(providers.Constructors()...),
fx.Invoke(
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
@@ -88,56 +88,3 @@ func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
},
})
}
// TestBlockedNetworksMappedEntryIsIPv4Range checks that an entry written in
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
// since the fetcher matches an IPv4-mapped address as the IPv4 address it
// holds. /96, the shortest such entry, covers every IPv4 address.
func TestBlockedNetworksMappedEntryIsIPv4Range(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine + `blocked_networks:
- "::ffff:10.0.0.0/104"
- "::ffff:0:0/96"
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("IPv4-mapped blocked_networks entries should load, got error: %v", err)
}
want := []string{"10.0.0.0/8", "0.0.0.0/0"}
if len(c.BlockedNetworks) != len(want) {
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
}
for i, w := range want {
if got := c.BlockedNetworks[i].String(); got != w {
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
}
}
}
// TestBlockedNetworksMappedEntryShorterThan96AbortsStartup checks that an
// entry in the IPv4-mapped form shorter than /96, which covers more than the
// IPv4 addresses, aborts startup with an error naming the key and the value.
func TestBlockedNetworksMappedEntryShorterThan96AbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "mapped-shorter-than-96",
yaml: signingKeyLine + `blocked_networks:
- "::ffff:0.0.0.0/80"
`,
wantErrSubstrings: []string{keyBlockedNetworks, "::ffff:0.0.0.0/80"},
},
{
name: "mapped-95",
yaml: signingKeyLine + `blocked_networks:
- "::ffff:0:0/95"
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
})
}
@@ -135,38 +135,10 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
}
}
// TestDefaultCacheMaxBytesAppliesFloor verifies that when 75% of the
// free space plus what the cache holds is below 500 MiB, the default is
// floored at DefaultCacheMaxBytesFloor.
func TestDefaultCacheMaxBytesAppliesFloor(t *testing.T) {
t.Parallel()
cases := []struct {
name string
freeBytes uint64
}{
{name: "100 MiB free", freeBytes: 104857600},
{name: "zero free", freeBytes: 0},
{name: "just below floor threshold", freeBytes: 699050665},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := DefaultCacheMaxBytes(tc.freeBytes, 0)
if got != DefaultCacheMaxBytesFloor {
t.Errorf("DefaultCacheMaxBytes = %d, want floor %d",
got, DefaultCacheMaxBytesFloor)
}
})
}
}
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
// cache_max_bytes is recorded as not explicit, so pixad works out the
// default when it builds the cache, and that an explicit zero is recorded
// as explicit, so it disables the disk cache instead.
// cache_max_bytes is recorded as not explicit, so the cache works out
// the default when it opens, and that an explicit zero is recorded as
// explicit, so it disables the disk cache instead.
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
t.Parallel()
+12 -61
View File
@@ -45,37 +45,6 @@ func DefaultMaxConcurrentProcessing() int {
return max(runtime.GOMAXPROCS(0)/2, 1) //nolint:mnd // half the CPUs
}
// DefaultCacheMaxBytesFloor is the least DefaultCacheMaxBytes returns:
// 500 MiB. It applies only to the default, never to an explicit
// cache_max_bytes.
const DefaultCacheMaxBytesFloor int64 = 500 << 20
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
// the 75% share of DefaultCacheMaxBytes as integer arithmetic (dividing
// before multiplying avoids overflow).
const (
freeSpaceFractionNumerator uint64 = 3
freeSpaceFractionDenominator uint64 = 4
)
// DefaultCacheMaxBytes returns the disk cache limit when cache_max_bytes
// is omitted: 75% of the sum of freeBytes, the space free on the
// filesystem holding the cache, and heldBytes, the bytes the cache
// already holds, and at least DefaultCacheMaxBytesFloor. Counting what
// the cache holds keeps the limit from shrinking as the cache fills.
// pixad works it out when it builds the cache, as both numbers are known
// only then.
func DefaultCacheMaxBytes(freeBytes uint64, heldBytes int64) int64 {
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
//nolint:gosec // G115: heldBytes is the cache's total, never negative
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(heldBytes)
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
computed = min(computed, math.MaxInt64)
return max(int64(computed), DefaultCacheMaxBytesFloor)
}
// Configuration key names.
const (
keyDebug = "debug"
@@ -122,7 +91,6 @@ var (
errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings")
errNotAValidCIDR = errors.New("not a valid CIDR network")
errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96")
errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry")
@@ -221,14 +189,18 @@ type Config struct {
// address, and an explicit list replaces the default.
TrustedProxies []netip.Prefix
// CacheMaxBytes is the disk cache size limit in bytes, as set; 0
// turns the disk cache off.
// CacheMaxBytes is the disk cache size limit in bytes. Only an
// explicit zero (CacheMaxBytesExplicit true) disables the disk
// cache. Zero with CacheMaxBytesExplicit false means
// cache_max_bytes was omitted, and the cache works out the default
// limit when it opens.
CacheMaxBytes int64
// CacheMaxBytesExplicit records whether cache_max_bytes was
// explicitly set, in the environment or the configuration file.
// Explicit values are used exactly as given. For an omitted key
// pixad works out DefaultCacheMaxBytes when it builds the cache.
// Explicit values are used exactly as given; for an omitted key the
// cache works out the default limit when it opens (see
// imgcache.CacheConfig.UseDefaultMaxBytes).
CacheMaxBytesExplicit bool
}
@@ -266,8 +238,8 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return nil, err
}
// An omitted cache_max_bytes is worked out and logged when pixad
// builds the cache.
// An omitted cache_max_bytes is worked out and logged when the
// cache opens.
if c.CacheMaxBytesExplicit {
log.Info("effective cache size limit",
"cache_max_bytes", c.CacheMaxBytes,
@@ -352,8 +324,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
}
// The default for an omitted cache_max_bytes is worked out when
// pixad builds the cache; here we only record whether the operator
// set the key explicitly.
// the cache opens; here we only record whether the operator set
// the key explicitly.
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
// Build DBURL from StateDir if not explicitly set. The derived URL
@@ -1276,13 +1248,6 @@ func defaultTrustedProxies() []netip.Prefix {
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
// aborts startup naming the key and the offending value; the default
// (an empty list) applies only to an omitted key.
//
// An entry written in the IPv4-mapped form (::ffff:a.b.c.d) is kept as the
// IPv4 range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. Both
// blocked_networks and trusted_proxies are matched against an IPv4-mapped
// address as the IPv4 address it holds, so the entry as written would match
// nothing. Such an entry shorter than /96 covers more than the IPv4
// addresses and aborts startup.
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
raw, ok := lookupValue(sc, key)
if !ok {
@@ -1298,10 +1263,6 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return nil, err
}
// An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by
// the 32 bits of the IPv4 address it holds.
const mappedPrefixBits = 96
prefixes := make([]netip.Prefix, 0, len(entries))
for _, entry := range entries {
@@ -1311,16 +1272,6 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
settingName(key), entry, errNotAValidCIDR)
}
if prefix.Addr().Is4In6() && prefix.Bits() < mappedPrefixBits {
return nil, fmt.Errorf("%s: value %q is %w",
settingName(key), entry, errMappedTooShort)
}
if prefix.Addr().Is4In6() {
prefix = netip.PrefixFrom(prefix.Addr().Unmap(),
prefix.Bits()-mappedPrefixBits)
}
prefixes = append(prefixes, prefix)
}
@@ -83,41 +83,3 @@ func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
},
})
}
// TestTrustedProxiesMappedEntryIsIPv4Range checks that an entry written in
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
// since the client address is matched as the IPv4 address it holds. /96, the
// shortest such entry, covers every IPv4 address.
func TestTrustedProxiesMappedEntryIsIPv4Range(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+
`trusted_proxies: ["::ffff:10.0.0.0/104", "::ffff:0:0/96"]`+"\n")
if err != nil {
t.Fatalf("IPv4-mapped trusted_proxies entries should load: %v", err)
}
got := make([]string, len(c.TrustedProxies))
for i, p := range c.TrustedProxies {
got[i] = p.String()
}
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,0.0.0.0/0" {
t.Errorf("TrustedProxies = %v, want 10.0.0.0/8 and 0.0.0.0/0", got)
}
}
// TestTrustedProxiesMappedEntryShorterThan96AbortsStartup checks that an
// entry in the IPv4-mapped form shorter than /96, which covers more than the
// IPv4 addresses, aborts startup with an error naming the key and the value.
func TestTrustedProxiesMappedEntryShorterThan96AbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "mapped-95",
yaml: signingKeyLine + `trusted_proxies: ["::ffff:0:0/95"]` + "\n",
wantErrSubstrings: []string{keyTrustedProxies, "::ffff:0:0/95"},
},
})
}
@@ -104,31 +104,40 @@ func writeConcurrently(t *testing.T, db *sql.DB) {
wg.Wait()
var sources int
var hits, sources int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM source_content`).
Scan(&sources)
err := db.QueryRowContext(ctx, `
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
FROM cache_stats WHERE id = 1
`).Scan(&hits, &sources)
if err != nil {
t.Fatalf("failed to count the writes: %v", err)
}
if sources != totalRequests {
t.Errorf("%d source_content rows, want %d", sources, totalRequests)
if hits != totalRequests || sources != totalRequests {
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
hits, sources, totalRequests)
}
}
// writeLikeOneRequest makes the writes one request and the eviction pass
// make: it stores a source, records a transformed image and deletes that
// record again.
// make: it counts a cache hit, stores a source, records a transformed image
// and deletes that record again.
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
_, err := db.ExecContext(ctx, `INSERT INTO source_content
(source_key, size_bytes) VALUES (?, 1)`, key)
_, err := db.ExecContext(ctx,
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
if err != nil {
return fmt.Errorf("counting a cache hit: %w", err)
}
_, err = db.ExecContext(ctx, `INSERT INTO source_content
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
if err != nil {
return fmt.Errorf("storing a source: %w", err)
}
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
(cache_key, size_bytes) VALUES (?, 1)`, key)
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
if err != nil {
return fmt.Errorf("recording a transformed image: %w", err)
}
+11 -11
View File
@@ -82,11 +82,7 @@ func ParseMigrationVersion(filename string) (int, error) {
return version, nil
}
// New opens the database and applies the migrations, so that the parts
// fx builds after it can read the database while they are built. Its
// stop hook closes the database. fx runs stop hooks in the reverse of the
// order it built their parts in, so the stop hooks of those parts run
// before this one.
// New creates a new Database instance.
func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := &Database{
log: params.Logger.Get(),
@@ -95,16 +91,20 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
s.log.Info("Database instantiated")
err := s.connect(context.Background())
if err != nil {
return nil, err
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s.log.Info("Database OnStart Hook")
return s.connect(ctx)
},
OnStop: func(_ context.Context) error {
s.log.Info("Database OnStop Hook")
return s.db.Close()
if s.db != nil {
return s.db.Close()
}
return nil
},
})
+4 -1
View File
@@ -159,7 +159,10 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
}
// Verify that the application tables created by 001.sql exist.
tables := []string{"source_content", "negative_cache"}
tables := []string{
"source_content", "source_metadata", "output_content",
"request_cache", "negative_cache", "cache_stats",
}
for _, table := range tables {
var count int
+82 -13
View File
@@ -1,27 +1,52 @@
-- Migration 001: Initial schema
-- Creates all tables for the pixa caching image proxy
-- Source images, one row per source URL
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
-- is the SHA-256 of the source's host, path and query
-- Source content blobs
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
CREATE TABLE IF NOT EXISTS source_content (
source_key TEXT PRIMARY KEY,
content_hash TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
ON source_content(last_accessed_at);
-- Source URL metadata - maps URLs to content hashes
-- JSON stored at: cache/metadata/<hostname>/<path_hash>.json
CREATE TABLE IF NOT EXISTS source_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_host TEXT NOT NULL,
source_path TEXT NOT NULL,
source_query TEXT NOT NULL DEFAULT '',
path_hash TEXT NOT NULL,
content_hash TEXT,
status_code INTEGER NOT NULL,
content_type TEXT,
response_headers TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
etag TEXT,
last_modified TEXT,
UNIQUE(source_host, source_path, source_query),
FOREIGN KEY (content_hash) REFERENCES source_content(content_hash)
);
CREATE INDEX IF NOT EXISTS idx_source_meta_host ON source_metadata(source_host);
CREATE INDEX IF NOT EXISTS idx_source_meta_path_hash ON source_metadata(path_hash);
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
-- Processed variant blobs
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key>. Tracked here
-- (like source content blobs above) so total cache usage is known
-- without a directory scan, and so LRU eviction has a timestamp to order
-- on. A cache hit does not read this table: it takes the content type
-- from the file's first bytes, and its last_accessed_at is written
-- later, in a batch.
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
-- sidecar with the content type). Tracked here (like source content
-- blobs above) so total cache usage is known without a directory scan,
-- and so LRU eviction has a timestamp to order on.
CREATE TABLE IF NOT EXISTS variant_content (
cache_key TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
content_type TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
@@ -95,19 +120,63 @@ BEGIN
WHERE id = 1;
END;
-- Negative cache: source URLs whose fetch failed with an upstream error
-- status, or whose first bytes did not match their Content-Type. pixa does
-- not fetch such a URL again until its row expires.
-- Output/transformed content blobs
-- Not written: transformed images are stored in cache/variants and
-- tracked in variant_content above.
CREATE TABLE IF NOT EXISTS output_content (
content_hash TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Request cache - maps full request params to output content
CREATE TABLE IF NOT EXISTS request_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT NOT NULL UNIQUE,
source_metadata_id INTEGER NOT NULL,
output_hash TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 85,
fit_mode TEXT NOT NULL DEFAULT 'cover',
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
access_count INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (source_metadata_id) REFERENCES source_metadata(id),
FOREIGN KEY (output_hash) REFERENCES output_content(content_hash)
);
CREATE INDEX IF NOT EXISTS idx_request_cache_key ON request_cache(cache_key);
CREATE INDEX IF NOT EXISTS idx_request_cache_source ON request_cache(source_metadata_id);
CREATE INDEX IF NOT EXISTS idx_request_cache_output ON request_cache(output_hash);
CREATE INDEX IF NOT EXISTS idx_request_cache_fetched ON request_cache(fetched_at);
-- Negative cache for failed fetches (404s, timeouts, etc.)
CREATE TABLE IF NOT EXISTS negative_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_host TEXT NOT NULL,
source_path TEXT NOT NULL,
source_query TEXT NOT NULL DEFAULT '',
status_code INTEGER NOT NULL,
error_message TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
UNIQUE(source_host, source_path, source_query)
);
CREATE INDEX IF NOT EXISTS idx_negative_cache_expires ON negative_cache(expires_at);
-- Cache statistics for monitoring
CREATE TABLE IF NOT EXISTS cache_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
hit_count INTEGER NOT NULL DEFAULT 0,
miss_count INTEGER NOT NULL DEFAULT 0,
upstream_fetch_count INTEGER NOT NULL DEFAULT 0,
upstream_fetch_bytes INTEGER NOT NULL DEFAULT 0,
transform_count INTEGER NOT NULL DEFAULT 0,
last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
-- A random value that a login session carries from its login. pixa makes it
-- when the table has no row, and each logout replaces it; a login session
-- carrying another value is refused, so logging out ends every login session.
+82 -62
View File
@@ -7,15 +7,18 @@ import (
"github.com/fxamacker/cbor/v2"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
)
// Default values for optional fields.
const (
DefaultQuality = 85
DefaultFormat = imagerequest.FormatJXL
DefaultFitMode = imagerequest.FitCover
DefaultFormat = imgcache.FormatJXL
DefaultFitMode = imgcache.FitCover
// HKDF salt for URL encryption key derivation
urlKeySalt = "pixa-urlenc-v1"
)
// Errors returned by encurl operations.
@@ -25,19 +28,19 @@ var (
ErrDecryptFailed = errors.New("failed to decrypt URL")
)
// payload contains all image request parameters in a compact format.
// Payload contains all image request parameters in a compact format.
// Field names are short to minimize encoded size.
// Fields with omitempty use sane defaults when absent.
type payload struct {
SourceHost string `cbor:"h"` // required
SourcePath string `cbor:"p"` // required
SourceQuery string `cbor:"q,omitempty"` // optional
Width int `cbor:"w,omitempty"` // 0 = original
Height int `cbor:"ht,omitempty"` // 0 = original
Format imagerequest.Format `cbor:"f,omitempty"` // default: jxl
Quality int `cbor:"ql,omitempty"` // default: 85
FitMode imagerequest.Fit `cbor:"fm,omitempty"` // default: cover
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
type Payload struct {
SourceHost string `cbor:"h"` // required
SourcePath string `cbor:"p"` // required
SourceQuery string `cbor:"q,omitempty"` // optional
Width int `cbor:"w,omitempty"` // 0 = original
Height int `cbor:"ht,omitempty"` // 0 = original
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
Quality int `cbor:"ql,omitempty"` // default: 85
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
}
// Generator creates and parses encrypted URL tokens.
@@ -45,35 +48,20 @@ type Generator struct {
key [seal.KeySize]byte
}
// NewGenerator creates an encrypted URL generator that encrypts its tokens
// with key.
func NewGenerator(key [seal.KeySize]byte) *Generator {
return &Generator{key: key}
// NewGenerator creates an encrypted URL generator with a key derived
// from the signing key.
func NewGenerator(signingKey string) (*Generator, error) {
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
if err != nil {
return nil, err
}
return &Generator{key: key}, nil
}
// Generate creates an encrypted URL token from the request's source and
// transform, each as the request holds it, and from expires; an empty format,
// a quality of 0 or an empty fit mode is left out of the token, and a zero
// expires makes a token that never expires. The token is CBOR-encoded,
// encrypted with NaCl secretbox, and base64url-encoded.
func (g *Generator) Generate(
req *imagerequest.Request, expires time.Time,
) (string, error) {
p := &payload{
SourceHost: req.Source.Host,
SourcePath: req.Source.Path,
SourceQuery: req.Source.Query,
Width: req.Transform.Size.Width,
Height: req.Transform.Size.Height,
Format: req.Transform.Format,
Quality: req.Transform.Quality,
FitMode: req.Transform.FitMode,
}
if !expires.IsZero() {
p.ExpiresAt = expires.Unix()
}
// Generate creates an encrypted URL token from the payload.
// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded.
func (g *Generator) Generate(p *Payload) (string, error) {
// CBOR encode the payload
data, err := cbor.Marshal(p)
if err != nil {
@@ -84,35 +72,40 @@ func (g *Generator) Generate(
return seal.Encrypt(g.key, data)
}
// Parse decrypts and validates an encrypted URL token and returns the
// request it holds, with default values for omitted optional fields, and its
// expiry. A token that never expires gives the zero time.
// Parse decrypts and validates an encrypted URL token.
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, error) {
func (g *Generator) Parse(token string) (*Payload, error) {
// Decrypt
data, err := seal.Decrypt(g.key, token)
if err != nil {
if errors.Is(err, seal.ErrDecryptionFailed) ||
errors.Is(err, seal.ErrInvalidPayload) {
return nil, time.Time{}, ErrDecryptFailed
return nil, ErrDecryptFailed
}
return nil, time.Time{}, err
return nil, err
}
// CBOR decode
var p payload
var p Payload
err = cbor.Unmarshal(data, &p)
if err != nil {
return nil, time.Time{}, ErrInvalidFormat
return nil, ErrInvalidFormat
}
// Check expiration (0 = never expires)
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
return nil, time.Time{}, ErrExpired
return nil, ErrExpired
}
return &p, nil
}
// ToImageRequest converts the payload to an ImageRequest.
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
// that never expires, gives the zero Expires.
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
format := p.Format
if format == "" {
format = DefaultFormat
@@ -133,17 +126,44 @@ func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, error
expires = time.Unix(p.ExpiresAt, 0)
}
return &imagerequest.Request{
Source: imagerequest.Source{
Host: p.SourceHost,
Path: p.SourcePath,
Query: p.SourceQuery,
return &imgcache.ImageRequest{
SourceHost: p.SourceHost,
SourcePath: p.SourcePath,
SourceQuery: p.SourceQuery,
Size: imgcache.Size{
Width: p.Width,
Height: p.Height,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: p.Width, Height: p.Height},
Format: format,
Quality: quality,
FitMode: fitMode,
},
}, expires, nil
Format: format,
Quality: quality,
FitMode: fitMode,
Expires: expires,
}
}
// FromImageRequest creates a Payload from an ImageRequest with the given expiration.
func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload {
p := &Payload{
SourceHost: req.SourceHost,
SourcePath: req.SourcePath,
SourceQuery: req.SourceQuery,
Width: req.Size.Width,
Height: req.Size.Height,
ExpiresAt: expiresAt.Unix(),
}
// Only set non-default values to benefit from omitempty
if req.Format != "" && req.Format != DefaultFormat {
p.Format = req.Format
}
if req.Quality != 0 && req.Quality != DefaultQuality {
p.Quality = req.Quality
}
if req.FitMode != "" && req.FitMode != DefaultFitMode {
p.FitMode = req.FitMode
}
return p
}
+187 -109
View File
@@ -6,8 +6,7 @@ import (
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Shared test fixture strings.
@@ -20,29 +19,24 @@ const (
func TestGenerator_GenerateAndParse(t *testing.T) {
t.Parallel()
keys, err := seal.DeriveKeys("test-signing-key-12345")
gen, err := encurl.NewGenerator("test-signing-key-12345")
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("NewGenerator() error = %v", err)
}
gen := encurl.NewGenerator(keys.EncryptedURLKey)
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testSourceHost,
Path: testSourcePath,
Query: testSourceQuery,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
Quality: 90,
FitMode: imagerequest.FitContain,
},
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
SourceQuery: testSourceQuery,
Width: 800,
Height: 600,
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
expires := time.Now().Add(time.Hour)
token, err := gen.Generate(req, expires)
token, err := gen.Generate(payload)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
@@ -51,73 +45,66 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
t.Fatal("Generate() returned empty token")
}
parsed, parsedExpires, err := gen.Parse(token)
parsed, err := gen.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
// Verify all fields
if parsed.Source.Host != req.Source.Host {
t.Errorf("SourceHost = %q, want %q", parsed.Source.Host, req.Source.Host)
if parsed.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
}
if parsed.Source.Path != req.Source.Path {
t.Errorf("SourcePath = %q, want %q", parsed.Source.Path, req.Source.Path)
if parsed.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
}
if parsed.Source.Query != req.Source.Query {
t.Errorf("SourceQuery = %q, want %q", parsed.Source.Query, req.Source.Query)
if parsed.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
}
if parsed.Transform.Size.Width != req.Transform.Size.Width {
t.Errorf("Width = %d, want %d",
parsed.Transform.Size.Width, req.Transform.Size.Width)
if parsed.Width != payload.Width {
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
}
if parsed.Transform.Size.Height != req.Transform.Size.Height {
t.Errorf("Height = %d, want %d",
parsed.Transform.Size.Height, req.Transform.Size.Height)
if parsed.Height != payload.Height {
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
}
if parsed.Transform.Format != req.Transform.Format {
t.Errorf("Format = %q, want %q",
parsed.Transform.Format, req.Transform.Format)
if parsed.Format != payload.Format {
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
}
if parsed.Transform.Quality != req.Transform.Quality {
t.Errorf("Quality = %d, want %d",
parsed.Transform.Quality, req.Transform.Quality)
if parsed.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
}
if parsed.Transform.FitMode != req.Transform.FitMode {
t.Errorf("FitMode = %q, want %q",
parsed.Transform.FitMode, req.Transform.FitMode)
if parsed.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
}
if parsedExpires.Unix() != expires.Unix() {
t.Errorf("ExpiresAt = %d, want %d", parsedExpires.Unix(), expires.Unix())
if parsed.ExpiresAt != payload.ExpiresAt {
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
}
}
func TestGenerator_Parse_Expired(t *testing.T) {
t.Parallel()
keys, _ := seal.DeriveKeys("test-signing-key-12345")
gen := encurl.NewGenerator(keys.EncryptedURLKey)
gen, _ := encurl.NewGenerator("test-signing-key-12345")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testSourceHost,
Path: testSourcePath,
},
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
}
token, err := gen.Generate(req, time.Now().Add(-time.Hour)) // Already expired
token, err := gen.Generate(payload)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
_, _, err = gen.Parse(token)
_, err = gen.Parse(token)
if err == nil {
t.Error("Parse() should fail for expired token")
}
@@ -130,10 +117,9 @@ func TestGenerator_Parse_Expired(t *testing.T) {
func TestGenerator_Parse_InvalidToken(t *testing.T) {
t.Parallel()
keys, _ := seal.DeriveKeys("test-signing-key-12345")
gen := encurl.NewGenerator(keys.EncryptedURLKey)
gen, _ := encurl.NewGenerator("test-signing-key-12345")
_, _, err := gen.Parse("not-a-valid-token")
_, err := gen.Parse("not-a-valid-token")
if err == nil {
t.Error("Parse() should fail for invalid token")
}
@@ -142,17 +128,15 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
func TestGenerator_Parse_TamperedToken(t *testing.T) {
t.Parallel()
keys, _ := seal.DeriveKeys("test-signing-key-12345")
gen := encurl.NewGenerator(keys.EncryptedURLKey)
gen, _ := encurl.NewGenerator("test-signing-key-12345")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testSourceHost,
Path: testSourcePath,
},
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
token, _ := gen.Generate(payload)
// Tamper with the token
tampered := []byte(token)
@@ -160,7 +144,7 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
tampered[10] ^= 0x01
}
_, _, err := gen.Parse(string(tampered))
_, err := gen.Parse(string(tampered))
if err == nil {
t.Error("Parse() should fail for tampered token")
}
@@ -169,78 +153,172 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
func TestGenerator_Parse_WrongKey(t *testing.T) {
t.Parallel()
keys1, _ := seal.DeriveKeys("signing-key-1")
keys2, _ := seal.DeriveKeys("signing-key-2")
gen1 := encurl.NewGenerator(keys1.EncryptedURLKey)
gen2 := encurl.NewGenerator(keys2.EncryptedURLKey)
gen1, _ := encurl.NewGenerator("signing-key-1")
gen2, _ := encurl.NewGenerator("signing-key-2")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testSourceHost,
Path: testSourcePath,
},
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen1.Generate(req, time.Now().Add(time.Hour))
token, _ := gen1.Generate(payload)
_, _, err := gen2.Parse(token)
_, err := gen2.Parse(token)
if err == nil {
t.Error("Parse() should fail with different signing key")
}
}
func TestGenerator_Parse_Defaults(t *testing.T) {
func TestPayload_ToImageRequest(t *testing.T) {
t.Parallel()
keys, _ := seal.DeriveKeys("test-signing-key-12345")
gen := encurl.NewGenerator(keys.EncryptedURLKey)
// Request with only required fields - should get defaults
token, err := gen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: testSourceHost,
Path: testSourcePath,
},
}, time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("Generate() error = %v", err)
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
SourceQuery: testSourceQuery,
Width: 800,
Height: 600,
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req, _, err := gen.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
req := payload.ToImageRequest()
if req.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
}
if req.Transform.Format != encurl.DefaultFormat {
t.Errorf("Format = %q, want default %q",
req.Transform.Format, encurl.DefaultFormat)
if req.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
}
if req.Transform.Quality != encurl.DefaultQuality {
t.Errorf("Quality = %d, want default %d",
req.Transform.Quality, encurl.DefaultQuality)
if req.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
}
if req.Transform.FitMode != encurl.DefaultFitMode {
t.Errorf("FitMode = %q, want default %q",
req.Transform.FitMode, encurl.DefaultFitMode)
if req.Size.Width != payload.Width {
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
}
if req.Size.Height != payload.Height {
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
}
if req.Format != payload.Format {
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
}
if req.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
}
if req.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
}
}
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
t.Parallel()
// Payload with only required fields - should get defaults
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req := payload.ToImageRequest()
if req.Format != encurl.DefaultFormat {
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
}
if req.Quality != encurl.DefaultQuality {
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
}
if req.FitMode != encurl.DefaultFitMode {
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
}
}
func TestFromImageRequest(t *testing.T) {
t.Parallel()
req := &imgcache.ImageRequest{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
SourceQuery: testSourceQuery,
Size: imgcache.Size{Width: 800, Height: 600},
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
}
expiresAt := time.Now().Add(time.Hour)
payload := encurl.FromImageRequest(req, expiresAt)
if payload.SourceHost != req.SourceHost {
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
}
if payload.SourcePath != req.SourcePath {
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
}
if payload.Width != req.Size.Width {
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
}
if payload.ExpiresAt != expiresAt.Unix() {
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
}
}
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
t.Parallel()
// Request with default values - payload should omit them for
// smaller encoding
req := &imgcache.ImageRequest{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
Format: encurl.DefaultFormat,
Quality: encurl.DefaultQuality,
FitMode: encurl.DefaultFitMode,
}
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
// These should be zero/empty because they match defaults
if payload.Format != "" {
t.Errorf("Format should be empty for default, got %q", payload.Format)
}
if payload.Quality != 0 {
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
}
if payload.FitMode != "" {
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
}
}
func TestGenerator_TokenIsURLSafe(t *testing.T) {
t.Parallel()
keys, _ := seal.DeriveKeys("test-signing-key-12345")
gen := encurl.NewGenerator(keys.EncryptedURLKey)
gen, _ := encurl.NewGenerator("test-signing-key-12345")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testSourceHost,
Path: testSourcePath,
},
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
token, _ := gen.Generate(payload)
// Check that token only contains URL-safe characters
for _, c := range token {
+43 -38
View File
@@ -16,7 +16,7 @@ import (
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/templates"
)
@@ -145,7 +145,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return
}
req, expiresAt, ttl, err := buildGenerateRequest(parsed, r.Form)
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
if err != nil {
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
http.StatusBadRequest)
@@ -153,20 +153,21 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return
}
// The image URL must fetch the source URL as it was written. The
// fetcher fetches over http only when allow_http is set.
fetchURL := req.Source.URL(s.config.AllowHTTP)
// The image URL must fetch the source URL as it was written
sourceRequest := payload.ToImageRequest()
sourceRequest.AllowHTTP = s.config.AllowHTTP
fetchURL := sourceRequest.SourceURL()
if !fetchedAsWritten(parsed, fetchURL) {
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
"written: pixa would fetch "+fetchURL.String(),
"written: pixa would fetch "+fetchURL,
r.Form, http.StatusBadRequest)
return
}
// Generate encrypted token
token, err := s.encGen.Generate(req, expiresAt)
token, err := s.encGen.Generate(payload)
if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
@@ -226,26 +227,27 @@ func parseSourceURL(sourceURL string) (*url.URL, error) {
// in RawPath when it is not the decoded path percent-encoded again.
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
// such as a space, so RawPath is compared itself.
func fetchedAsWritten(source, fetchURL *url.URL) bool {
func fetchedAsWritten(source *url.URL, fetchURL string) bool {
writtenPath := source.RawPath
if writtenPath == "" {
writtenPath = source.EscapedPath()
}
return source.User == nil && !source.ForceQuery &&
fetchURL.EscapedPath() == writtenPath &&
fetchURL.RawQuery == source.RawQuery
fetched, err := url.Parse(fetchURL)
return err == nil && source.User == nil && !source.ForceQuery &&
fetched.EscapedPath() == writtenPath &&
fetched.RawQuery == source.RawQuery
}
// buildGenerateRequest parses the numeric form fields and assembles the
// image request the encrypted URL's token is made from, and its expiry.
// ttl=0 means never expires (the expiry is the zero time). A non-numeric or
// out-of-range width, height, quality or ttl, or an unrecognized fit mode, is
// a client error naming the offending field. The format field is passed
// through unchecked.
func buildGenerateRequest(
// buildGeneratePayload parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
// non-numeric or out-of-range width, height, quality or ttl, or an
// unrecognized fit mode, is a client error naming the offending field. The
// format field is passed through unchecked.
func buildGeneratePayload(
parsed *url.URL, form url.Values,
) (*imagerequest.Request, time.Time, int, error) {
) (*encurl.Payload, time.Time, int, error) {
width, err := parseFormDimension(form, "width")
if err != nil {
return nil, time.Time{}, 0, err
@@ -267,39 +269,42 @@ func buildGenerateRequest(
return nil, time.Time{}, 0, err
}
fitMode := imagerequest.Fit(form.Get("fit"))
fitMode := imgcache.FitMode(form.Get("fit"))
err = imagerequest.ValidateFitMode(fitMode)
err = imgcache.ValidateFitMode(fitMode)
if err != nil {
return nil, time.Time{}, 0,
fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, form.Get("fit"))
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
}
var expiresAt time.Time
var (
expiresAt time.Time
expiresAtUnix int64
)
if ttl > 0 {
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
expiresAtUnix = expiresAt.Unix()
}
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: parsed.Host,
Path: parsed.Path,
Query: parsed.RawQuery,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: width, Height: height},
Format: imagerequest.Format(form.Get("format")),
Quality: quality,
FitMode: fitMode,
},
payload := &encurl.Payload{
SourceHost: parsed.Host,
SourcePath: parsed.Path,
SourceQuery: parsed.RawQuery,
Width: width,
Height: height,
Format: imgcache.ImageFormat(form.Get("format")),
Quality: quality,
FitMode: fitMode,
ExpiresAt: expiresAtUnix,
}
return req, expiresAt, ttl, nil
return payload, expiresAt, ttl, nil
}
// parseFormDimension reads an optional width or height form field. An empty
// value means "original size" (0). A non-numeric value, or one
// imagerequest.ValidateDimension rejects, is an error naming the field.
// imgcache.ValidateDimension rejects, is an error naming the field.
func parseFormDimension(form url.Values, field string) (int, error) {
raw := form.Get(field)
if raw == "" {
@@ -311,7 +316,7 @@ func parseFormDimension(form url.Values, field string) (int, error) {
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
}
err = imagerequest.ValidateDimension(field, value)
err = imgcache.ValidateDimension(field, value)
if err != nil {
return 0, err
}
+15 -6
View File
@@ -14,7 +14,6 @@ import (
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/session"
)
@@ -45,17 +44,27 @@ func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
keys, err := seal.DeriveKeys(testSigningKey)
sessMgr, err := session.NewManager(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("session.NewManager() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
if err != nil {
t.Fatalf("newCSRFProtect() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
config: cfg,
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
sessMgr: sessMgr,
encGen: encGen,
csrfProtect: protect,
}
r := chi.NewRouter()
@@ -11,7 +11,7 @@ import (
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Generator form field names, and a value that is not a number.
@@ -125,10 +125,10 @@ func TestGeneratePostRejectsBadField(t *testing.T) {
}
}
// TestBuildGenerateRequestDefaultAndLimits verifies that an empty quality
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
// takes the default, and that the largest accepted width, height, quality and
// ttl are accepted with an expiry still in the future.
func TestBuildGenerateRequestDefaultAndLimits(t *testing.T) {
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
t.Parallel()
parsed, err := url.Parse(testSourceURL)
@@ -136,19 +136,19 @@ func TestBuildGenerateRequestDefaultAndLimits(t *testing.T) {
t.Fatalf("url.Parse() error = %v", err)
}
req, _, _, err := buildGenerateRequest(parsed, url.Values{})
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
if err != nil {
t.Fatalf("empty form: error = %v", err)
}
if req.Transform.Quality != encurl.DefaultQuality {
if payload.Quality != encurl.DefaultQuality {
t.Errorf("empty quality gave %d, want %d",
req.Transform.Quality, encurl.DefaultQuality)
payload.Quality, encurl.DefaultQuality)
}
_, expiresAt, _, err := buildGenerateRequest(parsed, url.Values{
widthField: {strconv.Itoa(imagerequest.MaxDimension)},
heightField: {strconv.Itoa(imagerequest.MaxDimension)},
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
widthField: {strconv.Itoa(imgcache.MaxDimension)},
heightField: {strconv.Itoa(imgcache.MaxDimension)},
qualityField: {strconv.Itoa(maxQuality)},
ttlField: {strconv.Itoa(maxTTL)},
})
@@ -10,7 +10,6 @@ import (
"testing"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/session"
)
@@ -23,15 +22,15 @@ func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
var buf bytes.Buffer
keys, err := seal.DeriveKeys(testSigningKey)
sessMgr, err := session.NewManager(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("session.NewManager() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
config: &config.Config{SigningKey: testSigningKey},
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
sessMgr: sessMgr,
}
submittedKeys := []string{wrongKey, testSigningKey}
@@ -10,8 +10,7 @@ import (
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/session"
)
@@ -233,13 +232,8 @@ func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
db := setupTestDB(t)
keys, err := seal.DeriveKeys(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
}
sessMgr, err := session.NewManagerWithDatabase(
t.Context(), keys.SessionHashKey, keys.SessionBlockKey, db)
t.Context(), testSigningKey, db)
if err != nil {
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
}
@@ -320,7 +314,7 @@ func TestGeneratePost_URLServesImage(t *testing.T) {
sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"},
heightField: {"50"},
formatField: {string(imagerequest.FormatJPEG)},
formatField: {string(imgcache.FormatJPEG)},
})
if rec.Code != http.StatusOK {
@@ -353,7 +347,7 @@ func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"},
heightField: {"50"},
formatField: {string(imagerequest.FormatJPEG)},
formatField: {string(imgcache.FormatJPEG)},
ttlField: {"1"},
})
@@ -1,39 +0,0 @@
package handlers
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError, which
// answers the errors of both image routes, answers 400 naming the AVIF pixel
// limit when the image processor refuses an AVIF output above it, wrapped as
// the service wraps it. The request's source only goes into a log line, so it
// is left empty.
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
err := fmt.Errorf("image processing failed: %w: 4097x4096",
imageprocessor.ErrAVIFOutputTooLarge)
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
rec := httptest.NewRecorder()
h.respondImageError(rec, &imagerequest.Request{}, err)
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if !strings.Contains(rec.Body.String(), wantBody) {
t.Errorf("body = %q, want it to say %q", rec.Body.String(), wantBody)
}
}
+15 -6
View File
@@ -11,7 +11,6 @@ import (
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/session"
)
@@ -33,17 +32,27 @@ func newBodyLimitTestRouter(
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
keys, err := seal.DeriveKeys(testSigningKey)
sessMgr, err := session.NewManager(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("session.NewManager() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
if err != nil {
t.Fatalf("newCSRFProtect() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
config: cfg,
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
sessMgr: sessMgr,
encGen: encGen,
csrfProtect: protect,
}
r := chi.NewRouter()
@@ -1,278 +0,0 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"database/sql/driver"
"image"
"image/jpeg"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/signature"
)
// countingHost is the allowlisted host whose photoPath the server of
// newCountingDatabaseServer serves.
const countingHost = "counting.example.com"
// countingConnector opens connections to the SQLite database file name with
// sqliteDriver, each one counting in statements the statements run on it.
type countingConnector struct {
sqliteDriver driver.Driver
name string
statements *atomic.Int64
}
func (c countingConnector) Connect(context.Context) (driver.Conn, error) {
conn, err := c.sqliteDriver.Open(c.name)
if err != nil {
return nil, err
}
return countingConn{Conn: conn, statements: c.statements}, nil
}
func (c countingConnector) Driver() driver.Driver {
return c.sqliteDriver
}
// countingConn counts the statements run on its connection. It has only the
// methods of driver.Conn, so database/sql prepares every query and every
// other statement with Prepare, which counts it.
type countingConn struct {
driver.Conn
statements *atomic.Int64
}
func (c countingConn) Prepare(query string) (driver.Stmt, error) {
c.statements.Add(1)
return c.Conn.Prepare(query)
}
// newCountingDatabase returns a database with pixa's schema, in a file of
// its own and with one connection, and the count of the statements run on
// it once the schema is in place.
func newCountingDatabase(tb testing.TB) (*sql.DB, *atomic.Int64) {
tb.Helper()
path := filepath.Join(tb.TempDir(), "state.sqlite3")
schemaDB, err := sql.Open("sqlite", path)
if err != nil {
tb.Fatalf("opening the database: %v", err)
}
defer func() { _ = schemaDB.Close() }()
err = database.ApplyMigrations(tb.Context(), schemaDB, nil)
if err != nil {
tb.Fatalf("applying the migrations: %v", err)
}
statements := new(atomic.Int64)
db := sql.OpenDB(countingConnector{
sqliteDriver: schemaDB.Driver(),
name: path,
statements: statements,
})
db.SetMaxOpenConns(1)
tb.Cleanup(func() { _ = db.Close() })
return db, statements
}
// newCountingDatabaseServer returns a router for both image routes whose
// image cache uses newCountingDatabase, the count of the statements run on
// that database, and a URL on each route for a different image of
// photoPath on countingHost. Nothing writes the cache's access times in the
// background, so every statement counted comes from a request.
func newCountingDatabaseServer(
tb testing.TB,
) (http.Handler, *atomic.Int64, []string) {
tb.Helper()
db, statements := newCountingDatabase(tb)
var photo bytes.Buffer
err := jpeg.Encode(&photo, image.NewRGBA(image.Rect(0, 0, 100, 100)), nil)
if err != nil {
tb.Fatalf("encoding the photo: %v", err)
}
cache, err := imgcache.NewCache(db, imgcache.Config{
StateDir: tb.TempDir(),
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
if err != nil {
tb.Fatalf("imgcache.NewCache() error = %v", err)
}
svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache,
Fetcher: newMockFetcher(fstest.MapFS{
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
}),
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
Logger: slog.New(slog.DiscardHandler),
}, prometheus.NewRegistry())
if err != nil {
tb.Fatalf("imageproxy.NewProxy() error = %v", err)
}
keys, err := seal.DeriveKeys(testSigningKey)
if err != nil {
tb.Fatalf("seal.DeriveKeys() error = %v", err)
}
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
token, err := encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 40, Height: 40},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}, time.Time{})
if err != nil {
tb.Fatalf("encGen.Generate() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
imgSvc: svc,
encGen: encGen,
signer: signature.New(testSigningKey),
allowlist: allowlist.New([]string{countingHost}),
}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return r, statements, []string{
"/v1/image/" + countingHost + photoPath + "/50x50.jpeg",
"/v1/e/" + token + "/img.jpg",
}
}
// serveImage sends srv a GET for target and returns the response, failing
// the test unless it is a 200 with an image.
func serveImage(
tb testing.TB, srv http.Handler, target string,
) *httptest.ResponseRecorder {
tb.Helper()
rec := httptest.NewRecorder()
srv.ServeHTTP(rec,
httptest.NewRequestWithContext(tb.Context(), http.MethodGet, target, nil))
if rec.Code != http.StatusOK || rec.Body.Len() == 0 {
tb.Errorf("GET %s: %d with %d bytes, want 200 with an image",
target, rec.Code, rec.Body.Len())
}
return rec
}
// TestImageRoutes_CacheHitsMakeNoDatabaseCall serves an image on each image
// route once, which makes and caches it, then serves it again from the cache.
// Serving those cache hits must run no statement on the database.
func TestImageRoutes_CacheHitsMakeNoDatabaseCall(t *testing.T) {
t.Parallel()
srv, statements, targets := newCountingDatabaseServer(t)
for _, target := range targets {
before := statements.Load()
serveImage(t, srv, target)
afterMiss := statements.Load()
t.Logf("GET %s, a miss: %d statements", target, afterMiss-before)
if afterMiss == before {
t.Fatalf("GET %s, a miss: no statement counted, want some", target)
}
for range 3 {
rec := serveImage(t, srv, target)
cacheStatus := rec.Header().Get("X-Pixa-Cache")
if cacheStatus != string(imageproxy.CacheHit) {
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
target, cacheStatus, imageproxy.CacheHit)
}
}
if hitStatements := statements.Load() - afterMiss; hitStatements != 0 {
t.Errorf("GET %s: 3 cache hits ran %d statements, want 0",
target, hitStatements)
}
}
}
// BenchmarkHandleImage_CacheHits serves one cached image through the
// /v1/image/ route from b.RunParallel's goroutines, and reports the cache
// hits served per second. It fails if serving them ran any statement on the
// database. script/bench runs it.
func BenchmarkHandleImage_CacheHits(b *testing.B) {
srv, statements, targets := newCountingDatabaseServer(b)
target := targets[0]
serveImage(b, srv, target)
before := statements.Load()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
rec := serveImage(b, srv, target)
cacheStatus := rec.Header().Get("X-Pixa-Cache")
if cacheStatus != string(imageproxy.CacheHit) {
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
target, cacheStatus, imageproxy.CacheHit)
return
}
}
})
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "hits/s")
if hitStatements := statements.Load() - before; hitStatements != 0 {
b.Fatalf("%d cache hits ran %d statements, want 0", b.N, hitStatements)
}
}
@@ -0,0 +1,147 @@
package handlers
import (
"os"
"path/filepath"
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
)
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
// built from cache_max_bytes: omitted, the cache works out the default
// limit; 0 turns the disk cache off; a positive value is the limit,
// unchanged.
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
t.Parallel()
const oneGiB = 1 << 30
cases := []struct {
name string
cacheMaxBytes int64
cacheMaxBytesExplicit bool
wantMaxBytes int64
wantUseDefaultMaxBytes bool
wantDisableDiskCache bool
}{
{
name: "cache_max_bytes omitted",
cacheMaxBytes: 0,
cacheMaxBytesExplicit: false,
wantMaxBytes: 0,
wantUseDefaultMaxBytes: true,
wantDisableDiskCache: false,
},
{
name: "cache_max_bytes: 0",
cacheMaxBytes: 0,
cacheMaxBytesExplicit: true,
wantMaxBytes: 0,
wantUseDefaultMaxBytes: false,
wantDisableDiskCache: true,
},
{
name: "cache_max_bytes: 1 GiB",
cacheMaxBytes: oneGiB,
cacheMaxBytesExplicit: true,
wantMaxBytes: oneGiB,
wantUseDefaultMaxBytes: false,
wantDisableDiskCache: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
CacheMaxBytes: tc.cacheMaxBytes,
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
}
got := newCacheConfig(cfg, nil)
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
if got.MaxBytes != tc.wantMaxBytes {
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
}
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
t.Errorf("UseDefaultMaxBytes = %v, want %v",
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
}
if got.DisableDiskCache != tc.wantDisableDiskCache {
t.Errorf("DisableDiskCache = %v, want %v",
got.DisableDiskCache, tc.wantDisableDiskCache)
}
})
}
}
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
// and checks by whether the cache directories were created that the
// disk cache is on in the first case and off in the second.
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
cacheMaxBytesExplicit bool
wantDiskCache bool
}{
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
CacheMaxBytes: 0,
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
if err != nil {
t.Fatalf("database.New() error = %v", err)
}
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
if err != nil {
t.Fatalf("New() error = %v", err)
}
lc.RequireStart()
t.Cleanup(lc.RequireStop)
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
gotDiskCache := err == nil
if gotDiskCache != tc.wantDiskCache {
t.Errorf("cache directories created = %v, want %v",
gotDiskCache, tc.wantDiskCache)
}
})
}
}
+15 -5
View File
@@ -9,10 +9,15 @@ import (
"sneak.berlin/go/pixa/internal/seal"
)
// csrfKeySalt provides domain separation for the CSRF authentication key,
// derived from the signing key so tokens survive restarts without extra
// configuration and never reuse the session or encrypted-URL key material.
const csrfKeySalt = "pixa-csrf-v1"
// newCSRFProtect builds the CSRF-protection middleware for the
// state-mutating HTML form routes. The token lives in its own cookie,
// independent of the session cookie, so it also protects the login POST
// where no session exists yet (login CSRF). key signs that cookie.
// where no session exists yet (login CSRF).
//
// When plaintext is true (local HTTP development), requests are marked
// plaintext so the library neither demands an https Referer nor sets a
@@ -20,8 +25,13 @@ import (
// service runs behind a TLS-terminating proxy, so plaintext is false and
// the library enforces its https Referer origin check.
func newCSRFProtect(
key [seal.KeySize]byte, plaintext bool,
) func(http.Handler) http.Handler {
signingKey string, plaintext bool,
) (func(http.Handler) http.Handler, error) {
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
if err != nil {
return nil, err
}
protect := csrf.Protect(
key[:],
csrf.Path("/"),
@@ -30,7 +40,7 @@ func newCSRFProtect(
)
if !plaintext {
return protect
return protect, nil
}
return func(next http.Handler) http.Handler {
@@ -39,7 +49,7 @@ func newCSRFProtect(
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
})
}
}, nil
}
// CSRF returns the CSRF-protection middleware for the login and generator
@@ -0,0 +1,61 @@
package handlers
import (
"net/http"
"net/netip"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/logger"
)
// TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided builds the handlers as
// pixad does, in an fx app that provides no fetcher, and requests an image
// from 192.0.2.10, which is on the allowlist and in blocked_networks. The URL
// check accepts that address; only the dialer that refuses internal
// addresses checks blocked_networks, so the answer is 403 only if the
// fetcher the handlers build from the config connects with that dialer. Any
// other dialer would try to connect until the upstream fetch timeout, which
// is short so that the test then fails quickly.
func TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided(t *testing.T) {
t.Parallel()
const host = "192.0.2.10"
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
AllowlistHosts: []string{host},
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
UpstreamFetchTimeout: 2 * time.Second,
// With no connection slots, the fetch would fail before dialing.
UpstreamConnections: config.DefaultUpstreamConnections,
}
var h *Handlers
app := fxtest.New(t,
fx.Supply(cfg),
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New),
fx.Populate(&h),
)
app.RequireStart()
t.Cleanup(app.RequireStop)
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
rec := sendGet(t, r, photoURL(host))
checkErrorBody(t, rec, http.StatusForbidden, "forbidden")
}
@@ -1,203 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"image/color"
"log/slog"
"net/http"
"slices"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/signature"
)
// mismatchedSourcePath is the path of the source that
// TestFirstBytesNotMatchingContentType serves on allowlistedHost. The mock
// fetcher names a file's Content-Type from its extension, so the source is
// sent as image/png.
const mismatchedSourcePath = "/images/source.png"
// newRoutesFetchingThrough returns both image routes, and the Handlers behind
// them, whose image service fetches through fetcher, and which have
// allowlistedHost on their allowlist. The handlers and the image service log
// to log.
func newRoutesFetchingThrough(
t *testing.T, fetcher httpfetcher.Fetcher, log *slog.Logger,
) (*Handlers, http.Handler) {
t.Helper()
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache,
Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
Logger: log,
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("imageproxy.NewProxy() error = %v", err)
}
keys, err := seal.DeriveKeys(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
}
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
h := &Handlers{
log: log,
imgSvc: svc,
encGen: encGen,
signer: signature.New(testSigningKey),
allowlist: allowlist.New([]string{allowlistedHost}),
}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return h, r
}
// mismatchedSourcePlainURL returns the /v1/image/ URL of mismatchedSourcePath
// on allowlistedHost at 50x50 as a JPEG.
func mismatchedSourcePlainURL(*testing.T, *Handlers) string {
return "/v1/image/" + allowlistedHost + mismatchedSourcePath + "/50x50.jpeg"
}
// mismatchedSourceEncURL returns an encrypted /v1/e/ URL, made with h's
// generator, of mismatchedSourcePath on allowlistedHost at 50x50 as a JPEG.
func mismatchedSourceEncURL(t *testing.T, h *Handlers) string {
t.Helper()
token, err := h.encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: allowlistedHost,
Path: mismatchedSourcePath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Time{})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
return "/v1/e/" + token + "/img.jpg"
}
// loggedLevels reads the JSON log lines in logs and returns the messages
// logged at each level.
func loggedLevels(t *testing.T, logs *bytes.Buffer) map[string][]string {
t.Helper()
t.Logf("logged:\n%s", logs.String())
messages := make(map[string][]string)
dec := json.NewDecoder(logs)
for dec.More() {
var line struct {
Level string `json:"level"`
Msg string `json:"msg"`
}
err := dec.Decode(&line)
if err != nil {
t.Fatalf("decoding log line: %v", err)
}
messages[line.Level] = append(messages[line.Level], line.Msg)
}
return messages
}
// TestFirstBytesNotMatchingContentType checks that both image routes answer
// 415 for an upstream body whose first bytes are not those of the format its
// Content-Type names, and log it as a refused source, not as an error of
// pixa's. The next request for that source must answer 502 without fetching
// it again, as after an upstream error status.
func TestFirstBytesNotMatchingContentType(t *testing.T) {
t.Parallel()
bodies := map[string][]byte{
"JPEG sent as image/png": generateTestJPEG(t, 10, 10, color.Black),
"text sent as image/png": []byte("This is just text, not a PNG"),
"body too short to name a format": []byte("short"),
}
// The source's URL on each image route.
sourceURLs := map[string]func(*testing.T, *Handlers) string{
"plain URL": mismatchedSourcePlainURL,
"encrypted URL": mismatchedSourceEncURL,
}
for bodyName, body := range bodies {
for urlName, sourceURL := range sourceURLs {
t.Run(urlName+", "+bodyName, func(t *testing.T) {
t.Parallel()
fetcher := &countingFetcher{
Fetcher: httpfetcher.NewMock(fstest.MapFS{
allowlistedHost + mismatchedSourcePath: &fstest.MapFile{
Data: body,
},
}),
}
var logs bytes.Buffer
h, routes := newRoutesFetchingThrough(t, fetcher,
slog.New(slog.NewJSONHandler(&logs, nil)))
target := sourceURL(t, h)
checkErrorBody(t, sendGet(t, routes, target),
http.StatusUnsupportedMediaType, "unsupported source format")
levels := loggedLevels(t, &logs)
if len(levels[slog.LevelError.String()]) != 0 {
t.Errorf("logged at error level: %q",
levels[slog.LevelError.String()])
}
if !slices.Contains(levels[slog.LevelWarn.String()], "source refused") {
t.Error(`no "source refused" line logged at warning level`)
}
checkErrorBody(t, sendGet(t, routes, target),
http.StatusBadGateway, "upstream error")
if n := fetcher.fetches.Load(); n != 1 {
t.Errorf("upstream fetched %d times, want 1", n)
}
})
}
}
}
+19 -18
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"strings"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Errors for an Accept header that an auto URL cannot be served for.
@@ -18,17 +18,16 @@ var (
"image/jxl, image/avif, image/webp or image/jpeg")
)
// chooseAutoFormat returns the format to serve for the requested format: for
// the format auto, the format formatForAccept chooses from r's Accept header,
// and any other format as it is. For auto it adds Vary: Accept to the
// chooseAutoFormat replaces the format auto in req with the format
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
// response, which then depends on that header. It answers 400 for an Accept
// header that is not valid and 406 for one that allows none of the formats,
// and reports whether the image can be served.
// and reports whether req can be served. Any other format is left as it is.
func (s *Handlers) chooseAutoFormat(
w http.ResponseWriter, r *http.Request, requested imagerequest.Format,
) (imagerequest.Format, bool) {
if requested != imagerequest.FormatAuto {
return requested, true
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest,
) bool {
if req.Format != imgcache.FormatAuto {
return true
}
w.Header().Add("Vary", "Accept")
@@ -37,16 +36,18 @@ func (s *Handlers) chooseAutoFormat(
if errors.Is(err, errNotAcceptable) {
s.respondError(w, err.Error(), http.StatusNotAcceptable)
return "", false
return false
}
if err != nil {
s.respondError(w, err.Error(), http.StatusBadRequest)
return "", false
return false
}
return format, true
req.Format = format
return true
}
// formatForAccept returns the format an auto URL is served in for the Accept
@@ -55,26 +56,26 @@ func (s *Handlers) chooseAutoFormat(
// specific entry of image/jpeg, image/* and */* allows it, or when it names
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
// clients that cannot show them send image/* and */* too.
func formatForAccept(accept string) (imagerequest.Format, error) {
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
qualities, err := parseAccept(accept)
if err != nil {
return "", err
}
if len(qualities) == 0 {
return imagerequest.FormatJPEG, nil
return imgcache.FormatJPEG, nil
}
if qualities["image/jxl"] > 0 {
return imagerequest.FormatJXL, nil
return imgcache.FormatJXL, nil
}
if qualities["image/avif"] > 0 {
return imagerequest.FormatAVIF, nil
return imgcache.FormatAVIF, nil
}
if qualities["image/webp"] > 0 {
return imagerequest.FormatWebP, nil
return imgcache.FormatWebP, nil
}
// For JPEG, the most specific entry the header has decides
@@ -88,7 +89,7 @@ func formatForAccept(accept string) (imagerequest.Format, error) {
}
if quality > 0 {
return imagerequest.FormatJPEG, nil
return imgcache.FormatJPEG, nil
}
return "", errNotAcceptable
+39 -44
View File
@@ -12,7 +12,8 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// The content types the tests below expect.
@@ -32,24 +33,24 @@ func TestFormatForAccept(t *testing.T) {
tests := []struct {
name string
accept string
want imagerequest.Format
want imgcache.ImageFormat
wantErr error
}{
{"AVIF-capable browser",
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
imagerequest.FormatAVIF, nil},
imgcache.FormatAVIF, nil},
{"WebP-capable browser",
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
imagerequest.FormatWebP, nil},
{"WebP only", webpType, imagerequest.FormatWebP, nil},
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imagerequest.FormatJPEG, nil},
{"wildcard only", "*/*", imagerequest.FormatJPEG, nil},
{"image wildcard only", "image/*", imagerequest.FormatJPEG, nil},
{"absent", "", imagerequest.FormatJPEG, nil},
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imagerequest.FormatWebP, nil},
imgcache.FormatWebP, nil},
{"WebP only", webpType, imgcache.FormatWebP, nil},
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
{"absent", "", imgcache.FormatJPEG, nil},
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
imagerequest.FormatJPEG, nil},
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imagerequest.FormatAVIF, nil},
imgcache.FormatJPEG, nil},
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
{"PNG only", "image/png", "", errNotAcceptable},
@@ -75,33 +76,28 @@ func TestFormatForAccept(t *testing.T) {
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in
// the format auto. The first is signed with testSigningKey, the key the
// handlers that newSignedHostServer builds check signatures with. The second
// is made with h's generator.
// the format auto, made with h's image service and generator.
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
t.Helper()
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatAuto,
},
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAuto,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
token, err := h.encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatAuto,
},
}, time.Now().Add(time.Minute))
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatAuto,
ExpiresAt: time.Now().Add(time.Minute).Unix(),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
@@ -186,18 +182,17 @@ func TestFormatAuto_ChosenFromAccept(t *testing.T) {
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
t.Parallel()
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedForAVIF := generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatAVIF,
},
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAVIF,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
if target == signedForAVIF {
+132 -21
View File
@@ -4,6 +4,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
@@ -11,13 +12,13 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/session"
"sneak.berlin/go/pixa/internal/signature"
)
// Params defines dependencies for Handlers.
@@ -26,48 +27,70 @@ type Params struct {
Logger *logger.Logger
Healthcheck *healthcheck.Healthcheck
Database *database.Database
Config *config.Config
Keys seal.Keys
ImageService *imageproxy.Proxy
SessionManager *session.Manager
EncryptedURLGenerator *encurl.Generator
// Fetcher, when provided, fetches upstream images in place of the
// fetcher the handlers build from the config. Only tests provide one;
// pixad does not.
Fetcher httpfetcher.Fetcher `optional:"true"`
}
// Handlers provides HTTP request handlers.
type Handlers struct {
log *slog.Logger
hc *healthcheck.Healthcheck
db *database.Database
config *config.Config
imgSvc *imageproxy.Proxy
fetcher httpfetcher.Fetcher
imgSvc *imgcache.Service
imgCache *imgcache.Cache
sessMgr *session.Manager
encGen *encurl.Generator
csrfProtect func(http.Handler) http.Handler
// signer checks the signature of a /v1/image/ URL with signing_key.
signer *signature.Signer
// allowlist matches the hosts of allowlist_hosts, which a /v1/image/
// URL may name without a signature.
allowlist *allowlist.HostAllowList
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
// reports whether a URL's host is on that list.
refererBlocklist *allowlist.HostAllowList
}
// New creates a new Handlers instance.
func New(_ fx.Lifecycle, params Params) *Handlers {
return &Handlers{
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
if err != nil {
return nil, err
}
s := &Handlers{
log: params.Logger.Get(),
hc: params.Healthcheck,
db: params.Database,
config: params.Config,
imgSvc: params.ImageService,
sessMgr: params.SessionManager,
encGen: params.EncryptedURLGenerator,
csrfProtect: newCSRFProtect(params.Keys.CSRFKey, params.Config.Debug),
signer: signature.New(params.Config.SigningKey),
allowlist: allowlist.New(params.Config.AllowlistHosts),
fetcher: params.Fetcher,
csrfProtect: csrfProtect,
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
}
lc.Append(fx.Hook{
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
OnStart: func(_ context.Context) error {
return s.initImageService()
},
// The pending counts are written here, before the database's stop
// hook closes it.
OnStop: func(ctx context.Context) error {
if s.imgCache == nil {
return nil
}
return errors.Join(
s.imgCache.StopEviction(ctx),
s.imgCache.StopPendingCountWrites(ctx),
)
},
})
return s, nil
}
// WaitForProcessing waits until no image is being processed, or until ctx
@@ -76,6 +99,94 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
return s.imgSvc.WaitForProcessing(ctx)
}
// newCacheConfig builds the image cache's configuration from cfg.
// cache_max_bytes: 0 disables the disk cache entirely; any other value
// is the eviction limit in bytes; when it is omitted, the cache works
// out the default limit itself.
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
return imgcache.CacheConfig{
StateDir: cfg.StateDir,
NegativeTTL: imgcache.DefaultNegativeTTL,
MaxBytes: cfg.CacheMaxBytes,
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
Logger: log,
}
}
// initImageService initializes the image cache and service.
func (s *Handlers) initImageService() error {
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
if err != nil {
return err
}
s.imgCache = cache
// Background eviction: startup reconciliation, then periodic and
// write-pressure passes, and the removal of expired negative cache
// rows, which is all it does when the disk cache is disabled.
cache.StartEviction(imgcache.DefaultEvictionInterval)
// Writes the counts requests could not write by their deadline
cache.StartPendingCountWrites()
// Create the fetcher config
fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
if s.config.UpstreamConnectionsPerHost > 0 {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
}
fetcherCfg.MaxConnections = s.config.UpstreamConnections
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
// Create the service. With no fetcher provided, it builds its own from
// fetcherCfg.
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
FetcherConfig: fetcherCfg,
Fetcher: s.fetcher,
SigningKey: s.config.SigningKey,
Allowlist: s.config.AllowlistHosts,
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
Logger: s.log,
})
if err != nil {
return err
}
s.imgSvc = svc
s.log.Info("image service initialized")
// Initialize session manager (signing key is validated at config load
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
// The random value that each logout replaces is saved in the database,
// so it outlasts a restart.
sessMgr, err := session.NewManagerWithDatabase(
context.Background(), s.config.SigningKey, s.db.DB())
if err != nil {
return err
}
s.sessMgr = sessMgr
// Initialize encrypted URL generator
encGen, err := encurl.NewGenerator(s.config.SigningKey)
if err != nil {
return err
}
s.encGen = encGen
s.log.Info("session manager and URL generator initialized")
return nil
}
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+16 -25
View File
@@ -18,20 +18,15 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature"
)
// testFixtures contains test data.
type testFixtures struct {
handler *Handlers
service *imgcache.Service
goodHost string
}
@@ -50,37 +45,32 @@ func setupTestHandler(t *testing.T) *testFixtures {
tmpDir := t.TempDir()
db := setupTestDB(t)
cache, err := imgcache.NewCache(db, imgcache.Config{
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
StateDir: tmpDir,
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache,
Fetcher: newMockFetcher(mockFS),
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
}, prometheus.NewRegistry())
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: newMockFetcher(mockFS),
SigningKey: "test-signing-key-must-be-32-chars",
Allowlist: []string{goodHost},
})
if err != nil {
t.Fatalf("failed to create proxy: %v", err)
t.Fatalf("failed to create service: %v", err)
}
h := &Handlers{
imgSvc: svc,
log: slog.Default(),
signer: signature.New("test-signing-key-must-be-32-chars"),
allowlist: allowlist.New([]string{goodHost}),
imgSvc: svc,
log: slog.Default(),
}
return &testFixtures{
handler: h,
service: svc,
goodHost: goodHost,
}
}
@@ -131,9 +121,10 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
}
func (f *mockFetcher) Fetch(
_ context.Context, source imagerequest.Source,
_ context.Context, url string,
) (*httpfetcher.FetchResult, error) {
path := source.Host + source.Path
// Remove https:// prefix
path := url[8:] // Remove "https://"
data, err := fs.ReadFile(f.fs, path)
if err != nil {
+98 -150
View File
@@ -7,16 +7,13 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
"sneak.berlin/go/pixa/internal/imgcache"
)
// HandleImage handles the main image proxy route:
@@ -28,17 +25,17 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
return
}
req, sig, expires, ok := s.parseImageRequest(w, r)
req, ok := s.parseImageRequest(w, r)
if !ok {
return
}
// Validate signature if required
err := s.validateSignature(req, sig, expires)
err := s.imgSvc.ValidateRequest(req)
if err != nil {
s.log.Warn("signature validation failed",
"host", req.Source.Host,
"path", req.Source.Path,
"host", req.SourceHost,
"path", req.SourcePath,
"error", err,
)
s.respondError(w, "unauthorized", http.StatusUnauthorized)
@@ -46,7 +43,27 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
return
}
s.serveImage(w, r, req, expires)
// The signature covers the format auto, not the format chosen
if !s.chooseAutoFormat(w, r, req) {
return
}
// Get cache key for logging
cacheKey := imgcache.CacheKey(req)
// Get the image (from cache or fetch/process)
startTime := time.Now()
resp, err := s.imgSvc.Get(r.Context(), req)
if err != nil {
s.respondImageError(w, req, err)
return
}
defer func() { _ = resp.Content.Close() }()
s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
}
}
@@ -63,17 +80,17 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
}
// parseImageRequest parses the image URL's path and query parameters into
// an image request, and returns it with the URL's signature and expiry. On
// invalid input it writes an error response and returns false.
// an ImageRequest. On invalid input it writes an error response and
// returns false.
func (s *Handlers) parseImageRequest(
w http.ResponseWriter, r *http.Request,
) (*imagerequest.Request, string, time.Time, bool) {
) (*imgcache.ImageRequest, bool) {
// The path is parsed decoded once, as r.URL.Path holds it, and not from
// chi's wildcard: chi routes on the path as the client wrote it whenever
// that is not Go's own encoding of it, so its wildcard would still hold
// the %3F before a source query when, say, a "/" in the query is written
// as %2F
req, err := imagerequest.ParseImageURL(r.URL.Path)
parsed, err := imgcache.ParseImageURL(r.URL.Path)
if err != nil {
s.log.Warn("failed to parse image URL",
"path", r.URL.Path,
@@ -81,9 +98,12 @@ func (s *Handlers) parseImageRequest(
)
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
// Convert to ImageRequest
req := parsed.ToImageRequest()
// Parse signature params from query string. r.URL.Query() would silently
// drop a pair it cannot decode, such as q=80%, so that q would be served
// at 85; a query string that cannot be decoded is refused instead. A
@@ -94,7 +114,7 @@ func (s *Handlers) parseImageRequest(
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
r.URL.RawQuery, err), http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
for name, values := range query {
@@ -102,15 +122,17 @@ func (s *Handlers) parseImageRequest(
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
name), http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
}
expires, err := parseExpires(query)
req.Signature = query.Get("sig")
req.Expires, err = parseExpires(query)
if err != nil {
s.respondError(w, err.Error(), http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
// Parse optional quality and fit params. Only a q missing from the URL is
@@ -122,76 +144,51 @@ func (s *Handlers) parseImageRequest(
s.respondError(w, `invalid q: not a number, got ""`,
http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
req.Transform.Quality, err = parseFormInt(query, "q",
req.Quality, err = parseFormInt(query, "q",
encurl.DefaultQuality, minQuality, maxQuality)
if err != nil {
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
// Only a fit missing from the URL is cover. A fit in the URL that is not a
// fit mode is refused by the fit-mode check below; that check would take an
// empty fit as missing, so an empty one is refused here.
req.Transform.FitMode = imagerequest.Fit(query.Get("fit"))
if query.Has("fit") && req.Transform.FitMode == "" {
req.FitMode = imgcache.FitMode(query.Get("fit"))
if query.Has("fit") && req.FitMode == "" {
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
// Default fit mode if not set
if req.Transform.FitMode == "" {
req.Transform.FitMode = imagerequest.FitCover
if req.FitMode == "" {
req.FitMode = imgcache.FitCover
}
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
// route. Dimensions are already bounded by the path parser above; this
// also rejects an unrecognized fit mode with 400 instead of letting it
// reach the processor as a 500.
err = imagerequest.ValidateImageRequest(req)
err = imgcache.ValidateImageRequest(req)
if err != nil {
s.respondError(w, "invalid image request: "+err.Error(),
http.StatusBadRequest)
return nil, "", time.Time{}, false
return nil, false
}
return req, query.Get("sig"), expires, true
}
// validateSignature returns nil when the source host of req is on
// allowlist_hosts and names no port. Otherwise it verifies sig, the URL's
// signature of req, and expires, the URL's expiry.
func (s *Handlers) validateSignature(
req *imagerequest.Request, sig string, expires time.Time,
) error {
// An allowlist entry names no port, so a host followed by ":", with or
// without a port, needs a signature. Go's URL type reads the port as the
// fetcher does: in an IPv6 address written without brackets, such as
// 2001:db8::1:8443, it is what follows the last ":". This is checked here
// and not in allowlist.IsAllowed, which also matches referer_blocklist,
// where a Referer must be blocked whatever its port. Port and IsAllowed
// read only the host of a URL.
sourceURL := &url.URL{Host: req.Source.Host}
hostHasPort := sourceURL.Port() != "" ||
strings.HasSuffix(sourceURL.Host, ":")
if !hostHasPort && s.allowlist.IsAllowed(sourceURL) {
return nil
}
// Signature required for non-allowed hosts
return s.signer.Verify(req, sig, expires)
return req, true
}
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
// missing from the URL gives the zero time, which the signature check refuses
// as a missing exp. An exp in the URL that is not a whole number, an empty
// missing from the URL gives the zero time, which the signature check takes
// as no expiration. An exp in the URL that is not a whole number, an empty
// one included, is an error naming exp and the value.
func parseExpires(query url.Values) (time.Time, error) {
if !query.Has("exp") {
@@ -219,73 +216,55 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
}
}
// respondImageError answers err, the image service's error for req, on both
// image routes. It logs a 500 at error level, as pixa's own failure. It logs
// the other answers at warn level, since the client, the upstream or a full
// limit caused them. The 415 for a source refused for its first bytes is not
// logged here, because the image service logs it.
// respondImageError maps image retrieval errors to HTTP responses.
func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imagerequest.Request, err error,
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
) {
// An upstream body whose first bytes are not those of the format its
// Content-Type names is the upstream's error, not pixa's: the image
// service has logged it as a refused source.
if errors.Is(err, magic.ErrMagicByteMismatch) ||
errors.Is(err, magic.ErrUnknownFormat) ||
errors.Is(err, magic.ErrNotEnoughData) {
s.log.Error("failed to get image",
"host", req.SourceHost,
"path", req.SourcePath,
"error", err,
)
// Check for specific error types
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
s.respondError(w, "forbidden", http.StatusForbidden)
return
}
if errors.Is(err, httpfetcher.ErrUpstreamError) {
s.respondError(w, "upstream error", http.StatusBadGateway)
return
}
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
errors.Is(err, imageprocessor.ErrTooManyImages) ||
errors.Is(err, imgcache.ErrProcessingTimedOut) {
s.respondError(w, "server busy, try again later",
http.StatusServiceUnavailable)
return
}
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
return
}
var (
status int
message string
)
switch {
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
status, message = http.StatusForbidden, "forbidden"
case errors.Is(err, httpfetcher.ErrUpstreamError):
status, message = http.StatusBadGateway, "upstream error"
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
status, message = http.StatusGatewayTimeout, "upstream timeout"
case errors.Is(err, httpfetcher.ErrTooManyConnections),
errors.Is(err, imageprocessor.ErrTooManyImages),
errors.Is(err, imageproxy.ErrProcessingTimedOut):
status = http.StatusServiceUnavailable
message = "server busy, try again later"
case errors.Is(err, httpfetcher.ErrInvalidContentType),
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
status = http.StatusUnsupportedMediaType
message = "unsupported source format"
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
status = http.StatusBadRequest
message = fmt.Sprintf("size too large for the source: the side "+
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
"computed from its proportions is above %d",
imageprocessor.MaxOutputDimension)
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
status = http.StatusBadRequest
message = fmt.Sprintf("size too large for AVIF: the output "+
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels)
default:
s.log.Error("failed to get image",
"host", req.Source.Host,
"path", req.Source.Path,
"error", err,
)
s.respondError(w, "internal error", http.StatusInternalServerError)
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
return
}
s.log.Warn("failed to get image",
"host", req.Source.Host,
"path", req.Source.Path,
"error", err,
)
s.respondError(w, message, status)
s.respondError(w, "internal error", http.StatusInternalServerError)
}
// cacheControl returns the Cache-Control header for an image served through a
@@ -342,34 +321,13 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
return true
}
// serveImage serves the image that req asks for. Both image routes call it
// once they have the request and its URL's expiry, which ends the
// Cache-Control max-age. It chooses the format for the format auto, answers
// If-None-Match with 304 and HEAD with the headers only, and answers an error
// of the image service with respondImageError.
func (s *Handlers) serveImage(
// writeImageResponse writes headers and streams the image content,
// handling conditional and HEAD requests.
func (s *Handlers) writeImageResponse(
w http.ResponseWriter, r *http.Request,
req *imagerequest.Request, expires time.Time,
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
cacheKey imgcache.VariantKey, startTime time.Time,
) {
// The signature or the token covers the format auto, not the format chosen
format, ok := s.chooseAutoFormat(w, r, req.Transform.Format)
if !ok {
return
}
req.Transform.Format = format
startTime := time.Now()
resp, err := s.imgSvc.Get(r.Context(), req)
if err != nil {
s.respondImageError(w, req, err)
return
}
defer func() { _ = resp.Content.Close() }()
// Set response headers
w.Header().Set("Content-Type", resp.ContentType)
@@ -378,7 +336,7 @@ func (s *Handlers) serveImage(
}
// Cache control headers
w.Header().Set("Cache-Control", cacheControl(expires))
w.Header().Set("Cache-Control", cacheControl(req.Expires))
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
if notModified(w, r, resp.ETag) {
@@ -395,31 +353,21 @@ func (s *Handlers) serveImage(
// Stream the response
w.WriteHeader(http.StatusOK)
// A failed write is most often a client that has gone away, not pixa's
// failure, so it is logged at warn level
servedBytes, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Warn("failed to write response",
"request_id", middleware.GetReqID(r.Context()),
"host", req.Source.Host,
"path", req.Source.Path,
"served_bytes", servedBytes,
s.log.Error("failed to write response",
"error", err,
)
return
}
// Log cache status and timing after serving
duration := time.Since(startTime)
s.log.Info("image served",
"request_id", middleware.GetReqID(r.Context()),
"cache_key", imagerequest.CacheKey(req),
"host", req.Source.Host,
"path", req.Source.Path,
"cache_key", cacheKey,
"cache_status", resp.CacheStatus,
"duration_ms", duration.Milliseconds(),
"format", req.Transform.Format,
"format", req.Format,
"served_bytes", servedBytes,
"fetched_bytes", resp.FetchedBytes,
)
@@ -12,16 +12,9 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/signature"
)
// photoPath is the path of the JPEG that newSignedHostServer serves.
@@ -36,46 +29,37 @@ func newSignedHostServer(
) (*Handlers, http.Handler) {
t.Helper()
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
})
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
svc, err := imageproxy.NewProxy(&imageproxy.Config{
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: newMockFetcher(fstest.MapFS{
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
}),
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
Logger: log,
}, prometheus.NewRegistry())
SigningKey: testSigningKey,
Logger: log,
})
if err != nil {
t.Fatalf("imageproxy.NewProxy() error = %v", err)
t.Fatalf("imgcache.NewService() error = %v", err)
}
keys, err := seal.DeriveKeys(testSigningKey)
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
h := &Handlers{
log: log,
imgSvc: svc,
encGen: encGen,
signer: signature.New(testSigningKey),
allowlist: allowlist.New(nil),
log: log,
imgSvc: svc,
encGen: encGen,
}
r := chi.NewRouter()
@@ -121,18 +105,17 @@ func getMaxAge(t *testing.T, srv http.Handler, target string) int {
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
t.Parallel()
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
maxAge := getMaxAge(t, srv, signedURL)
if maxAge < 50 || maxAge > 60 {
@@ -185,13 +168,13 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
tests := []struct {
name string
expires time.Time
expiresAt int64
wantAtLeast int
wantAtMost int
}{
{"60 second TTL", time.Now().Add(time.Minute), 50, 60},
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour), 31536000, 31536000},
{"no TTL", time.Time{}, 31536000, 31536000},
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
{"no TTL", 0, 31536000, 31536000},
}
for _, tt := range tests {
@@ -200,16 +183,14 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
token, err := h.encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, tt.expires)
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatJPEG,
ExpiresAt: tt.expiresAt,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
+41 -238
View File
@@ -1,29 +1,19 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image/color"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature"
)
@@ -33,38 +23,29 @@ import (
const allowlistedHost = "allowed.example.com"
// newImageRoute returns the image route of a Handlers whose service fetches
// with fetcher, and which checks signatures with testSigningKey.
// with fetcher and checks signatures with testSigningKey.
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
t.Helper()
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache,
Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
}, prometheus.NewRegistry())
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: fetcher,
SigningKey: testSigningKey,
Allowlist: []string{allowlistedHost},
})
if err != nil {
t.Fatalf("failed to create proxy: %v", err)
t.Fatalf("failed to create service: %v", err)
}
h := &Handlers{
imgSvc: svc,
log: slog.New(slog.DiscardHandler),
signer: signature.New(testSigningKey),
allowlist: allowlist.New([]string{allowlistedHost}),
}
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
@@ -73,7 +54,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
}
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
// each of hosts, and answers any other source with an upstream error.
// each of hosts, and answers any other URL with an upstream error.
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
t.Helper()
@@ -100,27 +81,19 @@ func photoURLWithSig(host, sig string, expires time.Time) string {
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
}
// photoRequest returns the image request of photoURL(host) at the default
// quality and fit.
func photoRequest(host string) *imagerequest.Request {
return &imagerequest.Request{
Source: imagerequest.Source{
Host: host,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
}
// photoSignature returns the signature of photoURL(host) at the default
// quality and fit, made with key and expiring at expires.
func photoSignature(key, host string, expires time.Time) string {
return signature.New(key).Sign(photoRequest(host), expires)
return signature.New(key).Sign(&signature.Request{
SourceHost: host,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
}
// sendGet sends a GET for target to route and returns the response.
@@ -191,12 +164,7 @@ func TestHandleImage_ErrorAnswers(t *testing.T) {
photos := newPhotoFetcher(t,
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
// The real fetcher refuses localhost before any lookup or connection.
realFetcher := httpfetcher.New(&httpfetcher.Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
})
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
exp := time.Now().Add(time.Hour)
expired := time.Now().Add(-time.Hour)
@@ -301,9 +269,10 @@ func TestHandleImage_AllowlistOrSignature(t *testing.T) {
// host can be, and checks that each is refused with 400 naming the host as
// decoded: allowlistedHost with a character after it that a URL host cannot
// hold, percent-encoded, an IPv6 address without brackets, and brackets
// around something that is not an IPv6 address. Later steps put the host
// unparsed into the allowlist check, the cache key and the upstream URL, so
// such a host is refused first.
// around something that is not an IPv6 address. A "?" or "#" after
// allowlistedHost would otherwise end the host when the source URL is read,
// so the request would pass the allowlist and be fetched and cached under a
// host that is not the one fetched.
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
t.Parallel()
@@ -374,27 +343,26 @@ func TestHandleImage_HostForms_Served(t *testing.T) {
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
// source path or source query holds a "#", written as %23, and checks that
// each is refused with 400, the one signed for that source path included.
// A "#" starts a URL's fragment, so neither the source path nor the source
// query may hold one.
// each is refused with 400, the one signed for that source path included. The
// source URL would otherwise end at the "#" when it is fetched, so each text
// after it would fetch the image at photoPath and cache it under keys of its
// own.
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
t.Parallel()
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
exp := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath + "#x",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}, exp)
sig := signature.New(testSigningKey).Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: photoPath + "#x",
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: exp,
})
tests := []struct {
name string
@@ -420,168 +388,3 @@ func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
})
}
}
// failingFetcher answers every fetch with its error.
type failingFetcher struct {
err error
}
// Fetch returns f's error.
func (f failingFetcher) Fetch(
context.Context, imagerequest.Source,
) (*httpfetcher.FetchResult, error) {
return nil, f.err
}
// photoURLsByRoute returns, for each image route, the function that makes the
// URL of the JPEG at photoPath on signedHost on that route.
func photoURLsByRoute() map[string]func(*testing.T, *Handlers) string {
return map[string]func(*testing.T, *Handlers) string{
"/v1/image/": signedPhotoURL,
"/v1/e/": encPhotoURL,
}
}
// TestImageRoutes_SameErrorAnswers checks that both image routes answer each
// upstream fetch error below with the same status. They log it as "failed to
// get image" at the same level: error for the 500 and warn for the others.
// Each request gets routes of its own, so no request finds another's failed
// fetch in the negative cache.
func TestImageRoutes_SameErrorAnswers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
wantStatus int
wantLevel slog.Level
}{
{"blocked upstream address", httpfetcher.ErrSSRFBlocked,
http.StatusForbidden, slog.LevelWarn},
{"upstream error status",
fmt.Errorf("%w: status 404", httpfetcher.ErrUpstreamError),
http.StatusBadGateway, slog.LevelWarn},
{"no response headers within the fetch timeout",
httpfetcher.ErrUpstreamTimeout, http.StatusGatewayTimeout,
slog.LevelWarn},
{"no free upstream connection", httpfetcher.ErrTooManyConnections,
http.StatusServiceUnavailable, slog.LevelWarn},
{"Content-Type not a source format",
fmt.Errorf("%w: image/svg+xml", httpfetcher.ErrInvalidContentType),
http.StatusUnsupportedMediaType, slog.LevelWarn},
{"any other failure", io.ErrUnexpectedEOF,
http.StatusInternalServerError, slog.LevelError},
}
for _, tt := range tests {
for route, imageURL := range photoURLsByRoute() {
t.Run(route+" "+tt.name, func(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
h, routes := newRoutesFetchingThrough(t,
failingFetcher{err: tt.err},
slog.New(slog.NewJSONHandler(&logs, nil)))
rec := sendGet(t, routes, imageURL(t, h))
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d: %s",
rec.Code, tt.wantStatus, rec.Body)
}
levels := loggedLevels(t, &logs)
if !slices.Contains(levels[tt.wantLevel.String()],
"failed to get image") {
t.Errorf(`no "failed to get image" line logged at level %s`,
tt.wantLevel)
}
if tt.wantLevel != slog.LevelError &&
len(levels[slog.LevelError.String()]) != 0 {
t.Errorf("logged at error level: %q",
levels[slog.LevelError.String()])
}
})
}
}
}
// failingResponseWriter is a response writer whose Write always fails, as when
// the client has gone away.
type failingResponseWriter struct {
*httptest.ResponseRecorder
}
// Write writes nothing and returns io.ErrClosedPipe.
func (failingResponseWriter) Write([]byte) (int, error) {
return 0, io.ErrClosedPipe
}
// TestImageRoutes_FailedWriteLoggedOnce checks that both image routes log a
// failed write of the image once, at warn level, with the request's ID, the
// source host and path, the bytes served and the error. Neither may also log
// the image as served.
func TestImageRoutes_FailedWriteLoggedOnce(t *testing.T) {
t.Parallel()
const requestID = "test-request-id"
for route, imageURL := range photoURLsByRoute() {
t.Run(route, func(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
h, srv := newSignedHostServer(t,
slog.New(slog.NewJSONHandler(&logs, nil)))
ctx := context.WithValue(t.Context(),
middleware.RequestIDKey, requestID)
srv.ServeHTTP(failingResponseWriter{httptest.NewRecorder()},
httptest.NewRequestWithContext(
ctx, http.MethodGet, imageURL(t, h), nil))
t.Logf("logged:\n%s", logs.String())
var failedWrites []map[string]any
dec := json.NewDecoder(&logs)
for dec.More() {
var line map[string]any
err := dec.Decode(&line)
if err != nil {
t.Fatalf("decoding log line: %v", err)
}
switch line["msg"] {
case "image served":
t.Error(`"image served" logged after a failed write`)
case "failed to write response":
failedWrites = append(failedWrites, line)
}
}
if len(failedWrites) != 1 {
t.Fatalf(`"failed to write response" logged %d times, want 1`,
len(failedWrites))
}
want := map[string]any{
"level": slog.LevelWarn.String(),
"request_id": requestID,
"host": signedHost,
"path": photoPath,
"served_bytes": float64(0),
"error": io.ErrClosedPipe.Error(),
}
for key, wantValue := range want {
if got := failedWrites[0][key]; got != wantValue {
t.Errorf("%s = %v, want %v", key, got, wantValue)
}
}
})
}
}
@@ -5,12 +5,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature"
)
@@ -37,47 +36,6 @@ func getImage(t *testing.T, fix *testFixtures, target string) int {
return rec.Code
}
// generateSignedURL returns the /v1/image/ URL of req, signed with key and
// expiring after ttl. It signs a copy of req, so req itself is not changed.
// An unset quality or fit in the copy is set to 85 or cover, the values the
// route uses when a URL has no q or fit. The URL carries q and fit next to sig
// and exp, so the route verifies it for the quality and fit it was signed
// with.
func generateSignedURL(
key string, req *imagerequest.Request, ttl time.Duration,
) string {
signed := *req
if signed.Transform.Quality == 0 {
signed.Transform.Quality = 85
}
if signed.Transform.FitMode == "" {
signed.Transform.FitMode = imagerequest.FitCover
}
expires := time.Now().Add(ttl)
sig := signature.New(key).Sign(&signed, expires)
// A source query follows the source path after a "?". The image route
// reads the path decoded once, so the whole path is written in Go's own
// percent-encoding of a URL path, which writes "?" as %3F and "%" as %25,
// and leaves "/", ";", ",", "=" and "&" as they are.
sourcePathAndQuery := signed.Source.Path
if signed.Source.Query != "" {
sourcePathAndQuery += "?" + signed.Source.Query
}
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%dx%d.%s",
signed.Source.Host, sourcePathAndQuery,
signed.Transform.Size.Width, signed.Transform.Size.Height,
signed.Transform.Format)
return fmt.Sprintf("%s?sig=%s&exp=%d&q=%d&fit=%s",
(&url.URL{Path: unescapedPath}).EscapedPath(), sig, expires.Unix(),
signed.Transform.Quality, signed.Transform.FitMode)
}
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
// and fit cover, the values the route uses when a URL has no q or fit, and
// sends that signature with each q and fit below.
@@ -86,18 +44,16 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: "/images/photo.jpg",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}, expires)
sig := signer.Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: "/images/photo.jpg",
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
signedHost, sig, expires.Unix())
@@ -124,17 +80,17 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
}
}
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by
// generateSignedURL to the route.
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
// service's signed-URL generator to the route.
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
t.Parallel()
tests := []struct {
name string
quality int
fitMode imagerequest.Fit
fitMode imgcache.FitMode
}{
{"quality 40 and fit contain", 40, imagerequest.FitContain},
{"quality 40 and fit contain", 40, imgcache.FitContain},
{"quality and fit unset", 0, ""},
}
@@ -142,21 +98,21 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: "/images/photo.jpg",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: tt.quality,
FitMode: tt.fitMode,
},
}, time.Hour)
fix := setupTestHandler(t)
status := getImage(t, setupTestHandler(t), signedURL)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: "/images/photo.jpg",
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
Quality: tt.quality,
FitMode: tt.fitMode,
}, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL)
if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
}
@@ -215,10 +171,10 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
}
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
// by generateSignedURL for source URLs with a query to the route, through a
// chi router: chi routes on the path as the client wrote it whenever that is
// not Go's own encoding of it. A 502 means the signature passed, so the route
// read the source path and query that were signed.
// by the service's signed-URL generator for source URLs with a query to the
// route, through a chi router: chi routes on the path as the client wrote it
// whenever that is not Go's own encoding of it. A 502 means the signature
// passed, so the route read the source path and query that were signed.
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
t.Parallel()
@@ -238,20 +194,20 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
Query: tt.query,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Hour)
fix := setupTestHandler(t)
status := getImage(t, setupTestHandler(t), signedURL)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
SourceQuery: tt.query,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL)
if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
}
@@ -268,19 +224,17 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
Query: "url=https://origin.example.com/my%20cat.jpg",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}, expires)
sig := signer.Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: photoPath,
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
tests := []struct {
name string
@@ -312,7 +266,7 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
// source query that each hold %25, the percent-escape of a literal "%", and
// sends each to the route written as the README says, with that "%" written as
// %25, and as generateSignedURL writes it. A 502 means the signature passed.
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed.
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
t.Parallel()
@@ -334,19 +288,17 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: tt.sourcePath,
Query: tt.sourceQuery,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}, expires)
sig := signer.Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: tt.sourcePath,
SourceQuery: tt.sourceQuery,
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
@@ -357,23 +309,23 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
}
})
t.Run(tt.name+" written by generateSignedURL", func(t *testing.T) {
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) {
t.Parallel()
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: tt.sourcePath,
Query: tt.sourceQuery,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Hour)
fix := setupTestHandler(t)
status := getImage(t, setupTestHandler(t), signedURL)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: tt.sourcePath,
SourceQuery: tt.sourceQuery,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL)
if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
}
+118 -19
View File
@@ -2,13 +2,19 @@ package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imgcache"
)
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
@@ -20,59 +26,152 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
return
}
req, expires, ok := s.parseImageEncRequest(w, r)
if !ok {
ctx := r.Context()
start := time.Now()
req, ok := s.parseImageEncRequest(w, r)
if !ok || !s.chooseAutoFormat(w, r, req) {
return
}
s.serveImage(w, r, req, expires)
// Log the request
s.log.Debug("encrypted image request",
"host", req.SourceHost,
"path", req.SourcePath,
"dimensions", fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height),
"format", req.Format,
)
// Fetch and process the image (no signature validation
// needed - encrypted URL is trusted)
resp, err := s.imgSvc.Get(ctx, req)
if err != nil {
s.handleImageError(w, err)
return
}
defer func() { _ = resp.Content.Close() }()
// Set response headers
w.Header().Set("Content-Type", resp.ContentType)
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
// Cache headers: max-age ends at the URL's expiry
w.Header().Set("Cache-Control", cacheControl(req.Expires))
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
if notModified(w, r, resp.ETag) {
return
}
// A HEAD request gets the headers only
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
// Stream the response
written, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Error("failed to write response", "error", err)
return
}
// Log completion
duration := time.Since(start)
s.log.Info("image served",
"request_id", middleware.GetReqID(ctx),
"cache_key", imgcache.CacheKey(req),
"host", req.SourceHost,
"path", req.SourcePath,
"format", req.Format,
"cache_status", resp.CacheStatus,
"served_bytes", written,
"duration_ms", duration.Milliseconds(),
)
}
}
// parseImageEncRequest decrypts the token of an encrypted image URL into an
// image request and checks it, and returns it with the token's expiry. On a
// token that is missing, does not decrypt, has expired or asks for something
// not valid, it writes an error response and returns false.
// ImageRequest and checks it. On a token that is missing, does not decrypt,
// has expired or asks for something not valid, it writes an error response
// and returns false.
func (s *Handlers) parseImageEncRequest(
w http.ResponseWriter, r *http.Request,
) (*imagerequest.Request, time.Time, bool) {
) (*imgcache.ImageRequest, bool) {
// Extract token from URL
token := chi.URLParam(r, "token")
if token == "" {
s.respondError(w, "missing token", http.StatusBadRequest)
return nil, time.Time{}, false
return nil, false
}
// Decrypt and validate the payload
req, expires, err := s.encGen.Parse(token)
payload, err := s.encGen.Parse(token)
if err != nil {
if errors.Is(err, encurl.ErrExpired) {
s.log.Debug("encrypted URL expired", "error", err)
s.respondError(w, "URL has expired", http.StatusGone)
return nil, time.Time{}, false
return nil, false
}
s.log.Debug("invalid encrypted URL", "error", err)
s.log.Debug("failed to decrypt URL", "error", err)
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
return nil, time.Time{}, false
return nil, false
}
// Convert payload to ImageRequest
req := payload.ToImageRequest()
// Apply the same dimension and fit-mode bounds as the plain image
// route: a sealed payload is trusted for its origin, not for staying
// within limits. This refuses an over-limit size before the source is
// fetched. An unknown fit mode is a 400 here rather than a 500 from the
// processor.
err = imagerequest.ValidateImageRequest(req)
// within limits, so an over-limit size or unknown fit mode is a 400
// here rather than an out-of-memory or a 500 from the processor.
err = imgcache.ValidateImageRequest(req)
if err != nil {
s.log.Debug("encrypted URL failed validation", "error", err)
s.respondError(w, "invalid encrypted URL: "+err.Error(),
http.StatusBadRequest)
return nil, time.Time{}, false
return nil, false
}
return req, expires, true
return req, true
}
// handleImageError converts image service errors to HTTP responses.
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
s.respondError(w, "forbidden", http.StatusForbidden)
case errors.Is(err, httpfetcher.ErrUpstreamError):
s.respondError(w, "upstream error", http.StatusBadGateway)
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
case errors.Is(err, httpfetcher.ErrTooManyConnections),
errors.Is(err, imageprocessor.ErrTooManyImages),
errors.Is(err, imgcache.ErrProcessingTimedOut):
s.respondError(w, "server busy, try again later",
http.StatusServiceUnavailable)
case errors.Is(err, httpfetcher.ErrInvalidContentType),
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
"computed from its proportions is above %d",
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
default:
s.log.Error("image request failed", "error", err)
s.respondError(w, "internal error", http.StatusInternalServerError)
}
}
+16 -26
View File
@@ -6,13 +6,11 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/imgcache"
)
// newEncTestServer builds a router serving the encrypted-URL route with a
@@ -22,13 +20,11 @@ import (
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
t.Helper()
keys, err := seal.DeriveKeys(testSigningKey)
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
h := &Handlers{
log: slog.New(slog.DiscardHandler),
encGen: encGen,
@@ -58,15 +54,12 @@ func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: "cdn.example.com",
Path: "/photo.jpg",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 100000, Height: 100000},
},
}, time.Time{})
token, err := encGen.Generate(&encurl.Payload{
SourceHost: "cdn.example.com",
SourcePath: "/photo.jpg",
Width: 100000,
Height: 100000,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
@@ -86,16 +79,13 @@ func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: "cdn.example.com",
Path: "/photo.jpg",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: imagerequest.Fit("bogus"),
},
}, time.Time{})
token, err := encGen.Generate(&encurl.Payload{
SourceHost: "cdn.example.com",
SourcePath: "/photo.jpg",
Width: 800,
Height: 600,
FitMode: imgcache.FitMode("bogus"),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
@@ -9,8 +9,6 @@ import (
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/seal"
)
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
@@ -61,34 +59,32 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
photo := imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
},
photo := encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
}
valid, err := h.encGen.Generate(&photo, time.Time{})
valid, err := h.encGen.Generate(&photo)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
expired, err := h.encGen.Generate(&photo, time.Now().Add(-time.Minute))
expiredPhoto := photo
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
expired, err := h.encGen.Generate(&expiredPhoto)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
keys, err := seal.DeriveKeys("another-signing-key-fedcba9876543210")
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
otherGen := encurl.NewGenerator(keys.EncryptedURLKey)
otherKey, err := otherGen.Generate(&photo, time.Time{})
otherKey, err := otherGen.Generate(&photo)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
@@ -14,7 +14,7 @@ import (
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature"
)
@@ -58,18 +58,16 @@ func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
expires := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJXL,
Quality: encurl.DefaultQuality,
FitMode: imagerequest.FitCover,
},
}, expires)
sig := signature.New(testSigningKey).Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: string(imgcache.FormatJXL),
Quality: encurl.DefaultQuality,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
for _, size := range []string{"50x50.jxl", "50x50"} {
@@ -85,15 +83,12 @@ func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
token, err := h.encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
},
}, time.Time{})
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
+6 -6
View File
@@ -7,7 +7,7 @@ import (
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// jxlType is the content type of JPEG XL.
@@ -22,14 +22,14 @@ func TestFormatForAccept_JPEGXL(t *testing.T) {
tests := []struct {
name string
accept string
want imagerequest.Format
want imgcache.ImageFormat
}{
{"JPEG XL-capable browser",
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imagerequest.FormatJXL},
{"JPEG XL only", jxlType, imagerequest.FormatJXL},
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
{"JPEG XL only", jxlType, imgcache.FormatJXL},
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
imagerequest.FormatJXL},
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imagerequest.FormatAVIF},
imgcache.FormatJXL},
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
}
for _, tt := range tests {
@@ -10,16 +10,10 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/signature"
)
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
@@ -35,11 +29,11 @@ type countingFetcher struct {
// Fetch counts the fetch and passes it on.
func (f *countingFetcher) Fetch(
ctx context.Context, source imagerequest.Source,
ctx context.Context, url string,
) (*httpfetcher.FetchResult, error) {
f.fetches.Add(1)
return f.Fetcher.Fetch(ctx, source)
return f.Fetcher.Fetch(ctx, url)
}
// newRefererRoutes returns both image routes of a Handlers whose
@@ -53,41 +47,33 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
}
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
})
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache,
Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
}, prometheus.NewRegistry())
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: fetcher,
SigningKey: testSigningKey,
Allowlist: []string{allowlistedHost},
})
if err != nil {
t.Fatalf("imageproxy.NewProxy() error = %v", err)
t.Fatalf("imgcache.NewService() error = %v", err)
}
keys, err := seal.DeriveKeys(testSigningKey)
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
h := &Handlers{
log: slog.New(slog.DiscardHandler),
imgSvc: svc,
encGen: encGen,
signer: signature.New(testSigningKey),
allowlist: allowlist.New([]string{allowlistedHost}),
log: slog.New(slog.DiscardHandler),
imgSvc: svc,
encGen: encGen,
refererBlocklist: allowlist.New(
[]string{"leech.example", ".hotlinker.example"}),
}
+21 -24
View File
@@ -13,26 +13,26 @@ import (
"github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
// JPEG at photoPath on signedHost at 50x50. It is signed with testSigningKey,
// the key the handlers that newSignedHostServer builds check signatures with.
// It takes a Handlers it does not use, so that it has the type of encPhotoURL.
func signedPhotoURL(t *testing.T, _ *Handlers) string {
// JPEG at photoPath on signedHost at 50x50, made with h's image service.
func signedPhotoURL(t *testing.T, h *Handlers) string {
t.Helper()
return generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
return signedURL
}
// encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the
@@ -40,16 +40,13 @@ func signedPhotoURL(t *testing.T, _ *Handlers) string {
func encPhotoURL(t *testing.T, h *Handlers) string {
t.Helper()
token, err := h.encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{
Host: signedHost,
Path: photoPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Time{})
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatJPEG,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
+47 -34
View File
@@ -11,64 +11,77 @@ import (
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestServerBusyAnswers503 checks that respondImageError, which answers the
// errors of both image routes, answers 503 with a clear error when the image
// service gives up waiting for a free upstream connection or processing slot,
// wrapped as the service wraps them.
// TestServerBusyAnswers503 checks that both image routes answer 503 with
// a clear error when the image service gives up waiting for a free upstream
// connection or processing slot, wrapped as the service wraps them.
func TestServerBusyAnswers503(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imagerequest.Request{
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.jpg"},
}
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
} {
rec := httptest.NewRecorder()
h.respondImageError(rec, req, err)
plain := httptest.NewRecorder()
h.respondImageError(plain, req, err)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("for %v: status = %d, want %d",
err, rec.Code, http.StatusServiceUnavailable)
}
encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err)
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
t.Errorf("for %v: body = %q, want the server busy error",
err, rec.Body.String())
for route, rec := range map[string]*httptest.ResponseRecorder{
"/v1/image/": plain, "/v1/e/": encrypted,
} {
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("%s for %v: status = %d, want %d",
route, err, rec.Code, http.StatusServiceUnavailable)
}
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
t.Errorf("%s for %v: body = %q, want the server busy error",
route, err, rec.Body.String())
}
}
}
}
// TestProcessingTimedOutAnswers503 checks that respondImageError, which
// answers the errors of both image routes, answers 503 with the server busy
// error when the image service reports that processing stopped at the
// deadline of the request doing it, wrapped as the service wraps it. A request
// that waited for that processing gets this error while its own deadline may
// have time left. The request's source only goes into a log line, so it is
// left empty.
// TestProcessingTimedOutAnswers503 checks that respondImageError and
// handleImageError, which answer the errors of the two image routes, answer
// 503 with the server busy error when the image service reports that
// processing stopped at the deadline of the request doing it, wrapped as the
// service wraps it. A request that waited for that processing gets this error
// while its own deadline may have time left. The request's source only goes
// into a log line, so it is left empty.
func TestProcessingTimedOutAnswers503(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
err := fmt.Errorf("%w: failed to encode: %w",
imageproxy.ErrProcessingTimedOut, context.DeadlineExceeded)
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
rec := httptest.NewRecorder()
h.respondImageError(rec, &imagerequest.Request{}, err)
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
plain := httptest.NewRecorder()
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err)
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
t.Errorf("body = %q, want the server busy error", rec.Body.String())
for name, rec := range map[string]*httptest.ResponseRecorder{
"respondImageError": plain, "handleImageError": encrypted,
} {
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("%s: status = %d, want %d",
name, rec.Code, http.StatusServiceUnavailable)
}
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
t.Errorf("%s: body = %q, want the server busy error",
name, rec.Body.String())
}
}
}
@@ -10,21 +10,18 @@ import (
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
// answers the errors of both image routes, answers 415 with a clear error when
// the upstream answers with a content type pixa does not accept, such as SVG,
// and when orig is asked of a source in a format the processor cannot name,
// wrapped as the service wraps them.
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
// 415 with a clear error when the upstream answers with a content type pixa
// does not accept, such as SVG, and when orig is asked of a source in a
// format the processor cannot name, wrapped as the service wraps them.
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imagerequest.Request{
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.svg"},
}
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"}
for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
@@ -32,18 +29,26 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
fmt.Errorf("image processing failed: %w: unknown",
imageprocessor.ErrUnsupportedInputFormat),
} {
rec := httptest.NewRecorder()
h.respondImageError(rec, req, err)
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
plain := httptest.NewRecorder()
h.respondImageError(plain, req, err)
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("for %v: status = %d, want %d",
err, rec.Code, http.StatusUnsupportedMediaType)
}
encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err)
if !strings.Contains(rec.Body.String(), "unsupported source format") {
t.Errorf("for %v: body = %q, want the unsupported source format error",
err, rec.Body.String())
for route, rec := range map[string]*httptest.ResponseRecorder{
"HandleImage": plain, "HandleImageEnc": encrypted,
} {
t.Logf("%s for %v: %d %s", route, err, rec.Code, rec.Body.String())
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("%s for %v: status = %d, want %d",
route, err, rec.Code, http.StatusUnsupportedMediaType)
}
if !strings.Contains(rec.Body.String(), "unsupported source format") {
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
route, err, rec.Body.String())
}
}
}
}
@@ -1,211 +0,0 @@
package handlers
import (
"testing"
"time"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/signature"
)
// Addresses the port tests below put on the allowlist.
const (
allowlistedIPv4 = "203.0.113.5"
allowlistedIPv6 = "2001:db8::1"
)
// newSignatureChecker returns Handlers that check signatures with
// testSigningKey and have allowlistHosts on their allowlist, as New builds
// them from signing_key and allowlist_hosts.
func newSignatureChecker(allowlistHosts ...string) *Handlers {
return &Handlers{
signer: signature.New(testSigningKey),
allowlist: allowlist.New(allowlistHosts),
}
}
func TestValidateSignature_NonAllowlistedHost_NoSignature(t *testing.T) {
t.Parallel()
h := newSignatureChecker(allowlistedHost)
// Should fail validation - not allowlisted and no signature
err := h.validateSignature(photoRequest(signedHost), "", time.Time{})
if err == nil {
t.Error("validateSignature() expected error for non-allowlisted " +
"host without signature")
}
}
func TestValidateSignature_NonAllowlistedHost_ValidSignature(t *testing.T) {
t.Parallel()
h := newSignatureChecker(allowlistedHost)
req := photoRequest(signedHost)
// Generate a valid signature
expires := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(req, expires)
// Should pass validation
err := h.validateSignature(req, sig, expires)
if err != nil {
t.Errorf("validateSignature() error = %v", err)
}
}
func TestValidateSignature_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
t.Parallel()
h := newSignatureChecker(allowlistedHost)
req := photoRequest(signedHost)
// Generate an expired signature
expires := time.Now().Add(-time.Hour)
sig := signature.New(testSigningKey).Sign(req, expires)
// Should fail validation
err := h.validateSignature(req, sig, expires)
if err == nil {
t.Error("validateSignature() expected error for expired signature")
}
}
func TestValidateSignature_NonAllowlistedHost_InvalidSignature(t *testing.T) {
t.Parallel()
h := newSignatureChecker(allowlistedHost)
// Should fail validation
err := h.validateSignature(photoRequest(signedHost), "invalid-signature",
time.Now().Add(time.Hour))
if err == nil {
t.Error("validateSignature() expected error for invalid signature")
}
}
// TestValidateSignature_NoAllowlist checks that with no host on the
// allowlist, a request without a signature fails validation.
func TestValidateSignature_NoAllowlist(t *testing.T) {
t.Parallel()
h := newSignatureChecker()
err := h.validateSignature(photoRequest(signedHost), "", time.Time{})
if err == nil {
t.Error("validateSignature() expected error when no host is allowlisted")
}
}
// TestValidateSignature_SignatureExactHostMatch verifies that
// validateSignature enforces exact host matching for signatures. A
// signature for one host must not verify for a different host, even
// if they share a domain suffix.
func TestValidateSignature_SignatureExactHostMatch(t *testing.T) {
t.Parallel()
h := newSignatureChecker()
// Sign a request for signedHost
signedReq := photoRequest(signedHost)
expires := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(signedReq, expires)
// The original request should pass validation
t.Run("exact host passes", func(t *testing.T) {
t.Parallel()
err := h.validateSignature(signedReq, sig, expires)
if err != nil {
t.Errorf("validateSignature() exact host failed: %v", err)
}
})
// Try to reuse the signature with different hosts
tests := []struct {
name string
host string
}{
{"parent domain", "example.com"},
{"sibling subdomain", "images.example.com"},
{"deeper subdomain", "a." + signedHost},
{"evil suffix domain", signedHost + ".evil.com"},
{"prefixed host", "evil" + signedHost},
}
for _, tt := range tests {
t.Run(tt.name+" rejected", func(t *testing.T) {
t.Parallel()
err := h.validateSignature(photoRequest(tt.host), sig, expires)
if err == nil {
t.Errorf("validateSignature() should reject signature for "+
"host %q (signed for %q)", tt.host, signedHost)
}
})
}
}
// TestValidateSignature_HostWithPortNeedsSignature verifies that a host
// followed by a port, or by a colon with no port, matches no allowlist entry,
// so that a request for it without a signature is refused, while the same
// host with no port is allowlisted.
func TestValidateSignature_HostWithPortNeedsSignature(t *testing.T) {
t.Parallel()
h := newSignatureChecker(allowlistedHost, allowlistedIPv4, allowlistedIPv6)
tests := []struct {
host string
allowlisted bool
}{
{allowlistedHost, true},
{allowlistedHost + ":8443", false},
{allowlistedHost + ":443", false},
{allowlistedHost + ":", false},
{allowlistedIPv4, true},
{allowlistedIPv4 + ":8080", false},
{"[" + allowlistedIPv6 + "]", true},
{"[" + allowlistedIPv6 + "]:8443", false},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
t.Parallel()
err := h.validateSignature(photoRequest(tt.host), "", time.Time{})
t.Logf("validateSignature() for host %q with no signature: %v",
tt.host, err)
if allowlisted := err == nil; allowlisted != tt.allowlisted {
t.Errorf("host %q served without a signature = %v, want %v",
tt.host, allowlisted, tt.allowlisted)
}
})
}
}
// TestValidateSignature_UnbracketedIPv6PortNeedsSignature verifies that an
// allowlisted IPv6 address written without brackets and followed by a port,
// or by a colon with no port, needs a signature. Go's URL type reads
// 2001:db8::1:8443 as the host 2001:db8::1 with the port 8443.
func TestValidateSignature_UnbracketedIPv6PortNeedsSignature(t *testing.T) {
t.Parallel()
h := newSignatureChecker(allowlistedIPv6)
for _, host := range []string{allowlistedIPv6 + ":8443", allowlistedIPv6 + ":"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
err := h.validateSignature(photoRequest(host), "", time.Time{})
t.Logf("validateSignature() for host %q with no signature: %v",
host, err)
if err == nil {
t.Errorf("host %q served without a signature, want refused",
host)
}
})
}
}
@@ -7,7 +7,6 @@ import (
"net/http"
"net/netip"
"testing"
"time"
)
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
@@ -71,15 +70,12 @@ func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
t.Parallel()
transport := transportOf(t, New(&Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
// blocking it can only come from the operator-supplied list.
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")},
}))
cfg := DefaultConfig()
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
// blocking it can only come from the operator-supplied list.
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
transport := transportOf(t, New(cfg))
blocked := []string{
"198.51.100.5:80", // operator-supplied range
@@ -4,7 +4,6 @@ import (
"errors"
"net"
"testing"
"time"
)
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
@@ -14,12 +13,7 @@ func TestNewUsesCheckedDialerWithoutDialContext(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
transport := transportOf(t, New(&Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
}))
transport := transportOf(t, New(DefaultConfig()))
addr := srv.Listener.Addr().String()
@@ -38,25 +32,21 @@ func TestDialContextReplacesOnlyTheDialer(t *testing.T) {
srv := startUpstream(t)
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
f := New(&Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
AllowHTTP: true,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
DialContext: dialer.dialContext,
})
cfg := DefaultConfig()
cfg.AllowHTTP = true
cfg.DialContext = dialer.dialContext
f := New(cfg)
if body := fetchBody(t, f, "/image"); body != imagePayload {
t.Errorf("body = %q, want %q", body, imagePayload)
}
_, err := f.Fetch(testContext(t), imageSourceOn("127.0.0.1"))
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
}
_, err = f.Fetch(testContext(t), upstreamSource("/redirect/private"))
_, err = f.Fetch(testContext(t), upstreamURL("/redirect/private"))
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
}
+19 -92
View File
@@ -14,8 +14,6 @@ import (
"sync"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
@@ -31,9 +29,9 @@ const imagePayload = "fake-jpeg-bytes"
// would mean SSRF protection let a forbidden target reach the transport.
var errUnexpectedDial = errors.New("unexpected dial target")
// upstreamSource returns the source at path on the fake public host.
func upstreamSource(path string) imagerequest.Source {
return imagerequest.Source{Host: testPublicHost, Path: path}
// upstreamURL builds a fetch URL on the fake public host for the given path.
func upstreamURL(path string) string {
return "http://" + testPublicHost + path
}
// recordingDialer records every address the transport asks it to dial and
@@ -122,12 +120,7 @@ func newServerFetcher(
t.Helper()
if cfg == nil {
cfg = &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
}
cfg = DefaultConfig()
}
cfg.AllowHTTP = true
@@ -160,7 +153,7 @@ func testContext(t *testing.T) context.Context {
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
t.Helper()
res, err := f.Fetch(testContext(t), upstreamSource(path))
res, err := f.Fetch(testContext(t), upstreamURL(path))
if err != nil {
t.Fatalf("Fetch(%s) error = %v", path, err)
}
@@ -172,7 +165,7 @@ func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
t.Helper()
res, err := f.Fetch(testContext(t), upstreamSource(path))
res, err := f.Fetch(testContext(t), upstreamURL(path))
if err == nil {
_ = res.Content.Close()
@@ -224,7 +217,7 @@ func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
srv := startUpstream(t)
f, dialer := newServerFetcher(t, srv, nil)
_, err := f.Fetch(testContext(t), upstreamSource("/redirect/private"))
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
if !errors.Is(err, ErrSSRFBlocked) {
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
}
@@ -287,12 +280,8 @@ func TestFetchMaxResponseSizeEnforced(t *testing.T) {
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 8,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
}
cfg := DefaultConfig()
cfg.MaxResponseSize = 8
f, _ := newServerFetcher(t, srv, cfg)
@@ -321,12 +310,8 @@ func fetchWithMaxResponseSize(
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: maxResponseSize,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
}
cfg := DefaultConfig()
cfg.MaxResponseSize = maxResponseSize
f, _ := newServerFetcher(t, srv, cfg)
@@ -451,12 +436,8 @@ func TestFetchSemaphoreReleasedOnError(t *testing.T) {
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 1,
MaxConnections: 64,
}
cfg := DefaultConfig()
cfg.MaxConnectionsPerHost = 1
f, _ := newServerFetcher(t, srv, cfg)
@@ -485,12 +466,8 @@ func assertSlotReleasedByClose(
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 1,
MaxConnections: 64,
}
cfg := DefaultConfig()
cfg.MaxConnectionsPerHost = 1
f, _ := newServerFetcher(t, srv, cfg)
@@ -547,7 +524,7 @@ func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
})
}
// The dial-time re-resolution in dialSSRFSafe is what closes the DNS
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
// rebinding window: even if validateURL saw a public answer earlier, the
// dialer independently re-checks the address it is about to connect to. A full
// rebinding simulation (a resolver returning public, then private) would mean
@@ -567,9 +544,9 @@ func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
t.Run(addr, func(t *testing.T) {
t.Parallel()
_, err := dialSSRFSafe(context.Background(), "tcp", addr, nil)
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("dialSSRFSafe(%q) = %v, want ErrSSRFBlocked", addr, err)
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
}
})
}
@@ -583,7 +560,7 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := dialSSRFSafe(ctx, "tcp", testPublicHost+":80", nil)
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
if err == nil {
t.Fatal("expected a dial error for an unreachable public target")
}
@@ -592,53 +569,3 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
t.Errorf("public target was SSRF-blocked: %v", err)
}
}
// TestFetchSchemeFollowsAllowHTTP checks that the fetcher fetches a source
// over https, on port 443, unless AllowHTTP is set, and then over http, on
// port 80. The test server speaks plain HTTP, so only the fetch over http
// succeeds.
func TestFetchSchemeFollowsAllowHTTP(t *testing.T) {
t.Parallel()
tests := []struct {
allowHTTP bool
wantAddr string
}{
{false, net.JoinHostPort(testPublicHost, "443")},
{true, net.JoinHostPort(testPublicHost, "80")},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("AllowHTTP %v", tt.allowHTTP), func(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
f := New(&Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
AllowHTTP: tt.allowHTTP,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
DialContext: dialer.dialContext,
})
res, err := f.Fetch(testContext(t), upstreamSource("/image"))
if err == nil {
_ = res.Content.Close()
}
dialed := dialer.dialedAddrs()
t.Logf("Fetch(): error %v, dialed %v", err, dialed)
if served := err == nil; served != tt.allowHTTP {
t.Errorf("served = %v, want %v", served, tt.allowHTTP)
}
if len(dialed) != 1 || dialed[0] != tt.wantAddr {
t.Errorf("dialed %v, want only %s", dialed, tt.wantAddr)
}
})
}
}
+96 -59
View File
@@ -20,20 +20,20 @@ import (
"time"
"github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// Fetcher configuration constants.
const (
DefaultTLSTimeout = 10 * time.Second
DefaultMaxIdleConns = 100
DefaultIdleConnTimeout = 90 * time.Second
DefaultMaxRedirects = 10
DefaultFetchTimeout = 30 * time.Second
DefaultMaxResponseSize = 50 << 20 // 50MB
DefaultTLSTimeout = 10 * time.Second
DefaultMaxIdleConns = 100
DefaultIdleConnTimeout = 90 * time.Second
DefaultMaxRedirects = 10
DefaultMaxConnectionsPerHost = 20
DefaultMaxConnections = 64
)
// userAgent is the User-Agent header sent to upstream servers.
const userAgent = "pixa/1.0"
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
// MaxConnections fetches are already in progress.
const ConnectionWaitTimeout = 10 * time.Second
@@ -92,8 +92,8 @@ var (
// Fetcher retrieves content from upstream origins.
type Fetcher interface {
// Fetch retrieves the content of the source image.
Fetch(ctx context.Context, source imagerequest.Source) (*FetchResult, error)
// Fetch retrieves content from the given URL.
Fetch(ctx context.Context, url string) (*FetchResult, error)
}
// FetchResult contains the result of fetching from upstream.
@@ -120,28 +120,17 @@ type FetchResult struct {
TLSCipherSuite string
}
// allowedContentTypes returns the content types an upstream response may
// have, which Fetch also sends as its Accept header.
func allowedContentTypes() []string {
return []string{
contentTypeJPEG,
contentTypePNG,
contentTypeGIF,
contentTypeWebP,
contentTypeAVIF,
contentTypeJXL,
}
}
// Config holds configuration for the upstream fetcher. Every field but
// AllowHTTP, BlockedNetworks and DialContext must be set.
// Config holds configuration for the upstream fetcher.
type Config struct {
// Timeout for upstream requests.
Timeout time.Duration
// MaxResponseSize is the maximum allowed response body size.
MaxResponseSize int64
// AllowHTTP makes the fetcher fetch sources over http instead of https,
// and follow redirects to http (for testing only).
// UserAgent to send to upstream servers.
UserAgent string
// AllowedContentTypes is an allow list of MIME types to accept.
AllowedContentTypes []string
// AllowHTTP allows non-TLS connections (for testing only).
AllowHTTP bool
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
MaxConnectionsPerHost int
@@ -158,6 +147,26 @@ type Config struct {
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
}
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() *Config {
return &Config{
Timeout: DefaultFetchTimeout,
MaxResponseSize: DefaultMaxResponseSize,
UserAgent: "pixa/1.0",
AllowedContentTypes: []string{
contentTypeJPEG,
contentTypePNG,
contentTypeGIF,
contentTypeWebP,
contentTypeAVIF,
contentTypeJXL,
},
AllowHTTP: false,
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
MaxConnections: DefaultMaxConnections,
}
}
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
// per host and for all hosts together.
type HTTPFetcher struct {
@@ -185,6 +194,10 @@ type hostSemaphore struct {
// New creates a new HTTPFetcher with SSRF protection.
func New(config *Config) *HTTPFetcher {
if config == nil {
config = DefaultConfig()
}
// Unless config.DialContext replaces it, the transport connects with
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
// (closing the DNS-rebinding window) against both the built-in ranges
@@ -213,7 +226,7 @@ func New(config *Config) *HTTPFetcher {
}
// Validate the redirect target
err := validateURL(req.Context(), req.URL, config.AllowHTTP)
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
if err != nil {
return fmt.Errorf("redirect blocked: %w", err)
}
@@ -231,22 +244,18 @@ func New(config *Config) *HTTPFetcher {
}
}
// Fetch retrieves the source image with SSRF protection, over https, or over
// http when AllowHTTP is set. When MaxConnections fetches are already in
// progress, it waits up to ConnectionWaitTimeout for one to finish, then
// fails with ErrTooManyConnections.
func (f *HTTPFetcher) Fetch(
ctx context.Context, source imagerequest.Source,
) (*FetchResult, error) {
sourceURL := source.URL(f.config.AllowHTTP)
// Fetch retrieves content from the given URL with SSRF protection. When
// MaxConnections fetches are already in progress, it waits up to
// ConnectionWaitTimeout for one to finish, then fails with
// ErrTooManyConnections.
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// Validate URL before making request
err := validateURL(ctx, sourceURL, f.config.AllowHTTP)
err := validateURL(ctx, url, f.config.AllowHTTP)
if err != nil {
return nil, err
}
release, err := f.acquireConnection(ctx, source.Host)
release, err := f.acquireConnection(ctx, extractHost(url))
if err != nil {
return nil, err
}
@@ -260,14 +269,19 @@ func (f *HTTPFetcher) Fetch(
}
}()
parsedURL, err := neturl.Parse(url)
if err != nil {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
req := &http.Request{
Method: http.MethodGet,
URL: sourceURL,
URL: parsedURL,
Header: make(http.Header),
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", strings.Join(allowedContentTypes(), ", "))
req.Header.Set("User-Agent", f.config.UserAgent)
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
// The ID of the request this fetch serves, so the fetch can be found in
// the upstream host's logs
@@ -446,7 +460,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
// Extract the MIME type without parameters
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
for _, allowed := range allowedContentTypes() {
for _, allowed := range f.config.AllowedContentTypes {
if strings.EqualFold(mediaType, allowed) {
return true
}
@@ -456,14 +470,19 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
}
// validateURL checks if a URL is safe to fetch (not internal/private).
func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) error {
if !allowHTTP && fetchURL.Scheme != "https" {
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
return ErrUnsupportedScheme
}
parsedURL, err := neturl.Parse(rawURL)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidHost, err)
}
// Hostname drops any port, and the brackets of an IPv6 address whether
// or not a port follows them
host := fetchURL.Hostname()
host := parsedURL.Hostname()
if host == "" {
return ErrInvalidHost
}
@@ -489,6 +508,25 @@ func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) erro
return nil
}
// extractHost extracts the host from a URL string.
func extractHost(rawURL string) string {
// Simple extraction without full URL parsing
url := rawURL
if idx := strings.Index(url, "://"); idx != -1 {
url = url[idx+3:]
}
if idx := strings.Index(url, "/"); idx != -1 {
url = url[:idx]
}
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
return url
}
// isLocalhost checks if the host is localhost.
func isLocalhost(host string) bool {
host = strings.ToLower(host)
@@ -561,31 +599,23 @@ func isPrivateIP(ip net.IP) bool {
}
// isBlockedIP reports whether ip is refused, either by the built-in
// internal-range check or by one of the operator-supplied prefixes. An
// IPv4-compatible address (in ::/96) is refused when a prefix contains the
// address as written or the IPv4 address it holds. An IPv4-mapped address (in
// ::ffff:0:0/96) is matched only as the IPv4 address it holds: Go keeps a
// plain IPv4 address in that form too, and an IPv6 prefix such as ::/0 must
// not refuse every IPv4 address.
// internal-range check or by one of the operator-supplied prefixes.
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
if isPrivateIP(ip) {
return true
}
addr, ok := netip.AddrFromSlice(ip)
// An IPv4-compatible or IPv4-mapped IPv6 address is matched as the IPv4
// address it holds
addr, ok := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip))
if !ok {
return true
}
addr = addr.Unmap()
// ip read as IPv4 where it is or holds an IPv4 address, otherwise the
// zero netip.Addr, which no prefix contains. Matching asIPv4 as well as
// addr refuses more only for an IPv4-compatible address.
asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4())
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
return prefix.Contains(addr) || prefix.Contains(asIPv4)
return prefix.Contains(addr)
})
}
@@ -601,6 +631,13 @@ func ipv4CompatibleToIPv4(ip net.IP) net.IP {
return ip
}
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
// before connecting. New wraps dialSSRFSafe with the operator-supplied
// blocklist; this entry point enforces the built-in ranges alone.
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, nil)
}
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
// internal range or operator-supplied blocked prefix, closing the
// DNS-rebinding window at connect time.
@@ -5,26 +5,62 @@ import (
"errors"
"io"
"net"
"net/url"
"testing"
"testing/fstest"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// testHost is the hostname used by mock fetch tests.
const testHost = "example.com"
func TestDefaultConfig(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
if cfg.Timeout != DefaultFetchTimeout {
t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultFetchTimeout)
}
if cfg.MaxResponseSize != DefaultMaxResponseSize {
t.Errorf("MaxResponseSize = %d, want %d", cfg.MaxResponseSize, DefaultMaxResponseSize)
}
if cfg.MaxConnectionsPerHost != DefaultMaxConnectionsPerHost {
t.Errorf("MaxConnectionsPerHost = %d, want %d",
cfg.MaxConnectionsPerHost, DefaultMaxConnectionsPerHost)
}
if cfg.AllowHTTP {
t.Error("AllowHTTP should default to false")
}
if len(cfg.AllowedContentTypes) == 0 {
t.Error("AllowedContentTypes should not be empty")
}
}
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
t.Parallel()
f := New(nil)
if f == nil {
t.Fatal("New(nil) returned nil")
}
if f.config == nil {
t.Fatal("config should be populated from DefaultConfig")
}
if f.config.Timeout != DefaultFetchTimeout {
t.Errorf("Timeout = %v, want %v", f.config.Timeout, DefaultFetchTimeout)
}
}
func TestIsAllowedContentType(t *testing.T) {
t.Parallel()
f := New(&Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
})
f := New(DefaultConfig())
tests := []struct {
contentType string
@@ -52,6 +88,33 @@ func TestIsAllowedContentType(t *testing.T) {
}
}
func TestExtractHost(t *testing.T) {
t.Parallel()
tests := []struct {
url string
want string
}{
{"https://example.com/path", testHost},
{"http://example.com:8080/path", "example.com:8080"},
{"https://example.com", testHost},
{"https://example.com?q=1", testHost},
{"example.com/path", testHost},
{"", ""},
}
for _, tc := range tests {
t.Run(tc.url, func(t *testing.T) {
t.Parallel()
got := extractHost(tc.url)
if got != tc.want {
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
}
})
}
}
func TestIsLocalhost(t *testing.T) {
t.Parallel()
@@ -164,7 +227,7 @@ func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
t.Parallel()
err := validateURL(t.Context(), &url.URL{Scheme: "http", Host: testHost}, false)
err := validateURL(t.Context(), "http://example.com/path", false)
if !errors.Is(err, ErrUnsupportedScheme) {
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
}
@@ -174,8 +237,7 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
t.Parallel()
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
err := validateURL(t.Context(),
&url.URL{Scheme: "http", Host: "nonexistent.invalid"}, true)
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
// We expect a host resolution error, not ErrUnsupportedScheme.
if errors.Is(err, ErrUnsupportedScheme) {
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
@@ -185,7 +247,7 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
func TestValidateURL_RejectsLocalhost(t *testing.T) {
t.Parallel()
err := validateURL(t.Context(), &url.URL{Scheme: "https", Host: "localhost"}, false)
err := validateURL(t.Context(), "https://localhost/path", false)
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
}
@@ -194,7 +256,7 @@ func TestValidateURL_RejectsLocalhost(t *testing.T) {
func TestValidateURL_EmptyHost(t *testing.T) {
t.Parallel()
err := validateURL(t.Context(), &url.URL{Scheme: "https"}, false)
err := validateURL(t.Context(), "https:///path", false)
if !errors.Is(err, ErrInvalidHost) {
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
}
@@ -209,8 +271,7 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
m := NewMock(mockFS)
result, err := m.Fetch(context.Background(),
imagerequest.Source{Host: testHost, Path: "/images/photo.jpg"})
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
if err != nil {
t.Fatalf("Fetch() error = %v", err)
}
@@ -241,8 +302,7 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
mockFS := fstest.MapFS{}
m := NewMock(mockFS)
_, err := m.Fetch(context.Background(),
imagerequest.Source{Host: testHost, Path: "/missing.jpg"})
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
if !errors.Is(err, ErrUpstreamError) {
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
}
@@ -259,7 +319,7 @@ func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := m.Fetch(ctx, imagerequest.Source{Host: testHost, Path: "/photo.jpg"})
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
if !errors.Is(err, context.Canceled) {
t.Errorf("Fetch() error = %v, want context.Canceled", err)
}
+4 -14
View File
@@ -1,26 +1,16 @@
package httpfetcher
import (
"testing"
"time"
)
import "testing"
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
// of type image/jxl, and that the mock fetcher serves a .jxl file as
// image/jxl.
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
// as image/jxl.
func TestJPEGXLContentType(t *testing.T) {
t.Parallel()
const jxlType = "image/jxl"
f := New(&Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 64,
})
if !f.isAllowedContentType(jxlType) {
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
}
@@ -8,20 +8,22 @@ import (
"sync"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// imageSourceOn is the fake upstream's image route on host.
func imageSourceOn(host string) imagerequest.Source {
return imagerequest.Source{Host: host, Path: "/image"}
}
// imageSourceOnPort is the fake upstream's image route on testPublicHost at
// imageURLOnPort is the fake upstream's image route on testPublicHost at
// port. Each port is a different host to the per-host limit, while the test
// dialer sends every port to the one test server.
func imageSourceOnPort(port int) imagerequest.Source {
return imageSourceOn(net.JoinHostPort(testPublicHost, strconv.Itoa(port)))
func imageURLOnPort(port int) string {
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
"/image"
}
func TestDefaultConfigMaxConnections(t *testing.T) {
t.Parallel()
if got := DefaultConfig().MaxConnections; got != DefaultMaxConnections {
t.Errorf("MaxConnections = %d, want %d", got, DefaultMaxConnections)
}
}
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
@@ -35,22 +37,18 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 2,
}
cfg := DefaultConfig()
cfg.MaxConnections = 2
f, _ := newServerFetcher(t, srv, cfg)
f.connectionWaitTimeout = 100 * time.Millisecond
first, err := f.Fetch(testContext(t), imageSourceOnPort(81))
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
if err != nil {
t.Fatalf("first Fetch() error = %v", err)
}
second, err := f.Fetch(testContext(t), imageSourceOnPort(82))
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
if err != nil {
t.Fatalf("second Fetch() error = %v", err)
}
@@ -59,7 +57,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
start := time.Now()
_, err = f.Fetch(testContext(t), imageSourceOnPort(83))
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
if !errors.Is(err, ErrTooManyConnections) {
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
}
@@ -78,7 +76,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
t.Fatalf("close first body: %v", err)
}
third, err := f.Fetch(testContext(t), imageSourceOnPort(83))
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
if err != nil {
t.Fatalf("Fetch() after a response was closed: error = %v", err)
}
@@ -96,16 +94,12 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 1,
}
cfg := DefaultConfig()
cfg.MaxConnections = 1
f, _ := newServerFetcher(t, srv, cfg)
first, err := f.Fetch(testContext(t), imageSourceOnPort(81))
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
if err != nil {
t.Fatalf("first Fetch() error = %v", err)
}
@@ -115,7 +109,7 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err = f.Fetch(ctx, imageSourceOnPort(82))
_, err = f.Fetch(ctx, imageURLOnPort(82))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
}
@@ -140,7 +134,7 @@ func TestFetchRemovesIdleHostSemaphores(t *testing.T) {
for i := range 100 {
wg.Go(func() {
res, err := f.Fetch(ctx, imageSourceOnPort(1+i%50))
res, err := f.Fetch(ctx, imageURLOnPort(1+i%50))
if err != nil {
t.Errorf("Fetch() error = %v", err)
@@ -169,22 +163,19 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 1,
MaxConnections: 1,
}
cfg := DefaultConfig()
cfg.MaxConnections = 1
cfg.MaxConnectionsPerHost = 1
f, _ := newServerFetcher(t, srv, cfg)
f.connectionWaitTimeout = 100 * time.Millisecond
open, err := f.Fetch(testContext(t), imageSourceOnPort(81))
open, err := f.Fetch(testContext(t), imageURLOnPort(81))
if err != nil {
t.Fatalf("first Fetch() error = %v", err)
}
_, err = f.Fetch(testContext(t), imageSourceOnPort(82))
_, err = f.Fetch(testContext(t), imageURLOnPort(82))
if !errors.Is(err, ErrTooManyConnections) {
t.Fatalf("Fetch() from another host: error = %v, "+
"want ErrTooManyConnections", err)
@@ -193,7 +184,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err = f.Fetch(ctx, imageSourceOnPort(81))
_, err = f.Fetch(ctx, imageURLOnPort(81))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Fetch() from the busy host: error = %v, "+
"want context.DeadlineExceeded", err)
@@ -205,7 +196,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err = f.Fetch(ctx, imageSourceOnPort(83))
_, err = f.Fetch(ctx, imageURLOnPort(83))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
"want context.DeadlineExceeded", err)
@@ -228,15 +219,15 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
source imagerequest.Source
want error
name string
url string
want error
}{
{"upstream answers 500", upstreamSource("/status/500"), ErrUpstreamError},
{"upstream sends HTML", upstreamSource("/html"), ErrInvalidContentType},
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
// dialer refuses every host but testPublicHost.
{"connecting fails", imageSourceOn("198.51.100.7"), errUnexpectedDial},
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
}
for _, tc := range cases {
@@ -245,17 +236,13 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
srv := startUpstream(t)
cfg := &Config{
Timeout: 30 * time.Second,
MaxResponseSize: 50 << 20,
MaxConnectionsPerHost: 20,
MaxConnections: 1,
}
cfg := DefaultConfig()
cfg.MaxConnections = 1
f, _ := newServerFetcher(t, srv, cfg)
f.connectionWaitTimeout = 100 * time.Millisecond
_, err := f.Fetch(testContext(t), tc.source)
_, err := f.Fetch(testContext(t), tc.url)
if !errors.Is(err, tc.want) {
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
}
+37 -9
View File
@@ -7,14 +7,14 @@ import (
"io/fs"
"net/http"
"strings"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// errEmptyURLPath is returned when a mock URL has no usable path.
var errEmptyURLPath = errors.New("empty URL path")
// MockFetcher implements Fetcher using an embedded filesystem.
// Files are organized as: hostname/path/to/file.ext
// The source with host example.com and path /images/photo.jpg maps to
// example.com/images/photo.jpg; the source query is ignored.
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
type MockFetcher struct {
fs fs.FS
}
@@ -24,10 +24,8 @@ func NewMock(fsys fs.FS) *MockFetcher {
return &MockFetcher{fs: fsys}
}
// Fetch retrieves the source's content from the mock filesystem.
func (m *MockFetcher) Fetch(
ctx context.Context, source imagerequest.Source,
) (*FetchResult, error) {
// Fetch retrieves content from the mock filesystem.
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// Check context cancellation
select {
case <-ctx.Done():
@@ -35,7 +33,11 @@ func (m *MockFetcher) Fetch(
default:
}
path := source.Host + source.Path
// Parse URL to get filesystem path
path, err := urlToFSPath(url)
if err != nil {
return nil, err
}
// Open the file
f, err := m.fs.Open(path)
@@ -66,6 +68,32 @@ func (m *MockFetcher) Fetch(
}, nil
}
// urlToFSPath converts a URL to a filesystem path.
// https://example.com/images/photo.jpg -> example.com/images/photo.jpg
func urlToFSPath(rawURL string) (string, error) {
// Strip scheme
url := rawURL
if idx := strings.Index(url, "://"); idx != -1 {
url = url[idx+3:]
}
// Remove query string
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
// Remove fragment
if idx := strings.Index(url, "#"); idx != -1 {
url = url[:idx]
}
if url == "" {
return "", errEmptyURLPath
}
return url, nil
}
// detectContentTypeFromPath returns the MIME type based on file extension.
func detectContentTypeFromPath(path string) string {
path = strings.ToLower(path)
@@ -34,7 +34,7 @@ func TestFetchSendsRequestID(t *testing.T) {
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
res, err := f.Fetch(ctx, upstreamSource("/image"))
res, err := f.Fetch(ctx, upstreamURL("/image"))
if err != nil {
t.Fatalf("Fetch() error = %v", err)
}
@@ -1,97 +0,0 @@
package imageprocessor
import (
"bytes"
"encoding/binary"
"errors"
"os"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
// of more than MaxAVIFOutputPixels is refused with ErrAVIFOutputTooLarge
// before anything is decoded, whatever size and fit give it, the source's own
// size included, and that an AVIF output of at most that many pixels, or one
// in another format, is not. One input is a 64x32 PNG cut off in its pixel
// data. The other is testdata/red.avif, a 4x4 AVIF, with the size in its
// header changed to 4097x4096. libvips reads each input's size from its
// header but fails when it decodes its pixel data, so a request that gets
// that failure was not refused, and a refusal came before any decoding.
func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
t.Parallel()
whole := createTestPNG(t, 64, 32)
cutOff := whole[:len(whole)/2]
largeAVIF, err := os.ReadFile("testdata/red.avif")
if err != nil {
t.Fatalf("failed to read test AVIF: %v", err)
}
// The ispe box gives the width and then the height, 4 bytes each, after
// the box's type and its 4 bytes of version and flags
ispe := bytes.Index(largeAVIF, []byte("ispe"))
binary.BigEndian.PutUint32(largeAVIF[ispe+8:], 4097)
binary.BigEndian.PutUint32(largeAVIF[ispe+12:], 4096)
tests := []struct {
name string
input []byte
format imagerequest.Format
width, height int
fit imagerequest.Fit
refused bool
}{
{"AVIF at the limit", cutOff, imagerequest.FormatAVIF,
4096, 4096, imagerequest.FitCover, false},
{"AVIF above the limit", cutOff, imagerequest.FormatAVIF,
4096, 4097, imagerequest.FitCover, true},
{"WebP above the AVIF limit", cutOff, imagerequest.FormatWebP,
4096, 4097, imagerequest.FitCover, false},
// FitInside does not enlarge, so the output stays 64x32
{"AVIF inside a larger size", cutOff, imagerequest.FormatAVIF,
8192, 8192, imagerequest.FitInside, false},
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
{"AVIF outside a smaller size", cutOff, imagerequest.FormatAVIF,
8192, 1, imagerequest.FitOutside, true},
// With no size, the output keeps the source's 4097x4096
{"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
0, 0, imagerequest.FitCover, true},
{"orig of an AVIF source", largeAVIF, imagerequest.FormatOriginal,
0, 0, imagerequest.FitCover, true},
{"JPEG of the source's size", largeAVIF, imagerequest.FormatJPEG,
0, 0, imagerequest.FitCover, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(), bytes.NewReader(tt.input),
&imagerequest.Transform{
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
Format: tt.format,
FitMode: tt.fit,
})
t.Logf("Process() error = %v", err)
if tt.refused {
if !errors.Is(err, ErrAVIFOutputTooLarge) {
t.Errorf("Process() error = %v, want %v", err, ErrAVIFOutputTooLarge)
}
return
}
if err == nil || errors.Is(err, ErrAVIFOutputTooLarge) {
t.Errorf("Process() error = %v, want the failure to decode "+
"the input", err)
}
})
}
}
@@ -4,8 +4,6 @@ import (
"bytes"
"errors"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
@@ -19,17 +17,13 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
tests := []struct {
name string
inputWidth, inputHeight int
size imagerequest.Size
size Size
wantErr error
}{
{"width at the maximum", 4096, 1,
imagerequest.Size{Height: 2}, nil},
{"width above the maximum", 4096, 1,
imagerequest.Size{Height: 3}, ErrOutputTooLarge},
{"height at the maximum", 1, 4096,
imagerequest.Size{Width: 2}, nil},
{"height above the maximum", 1, 4096,
imagerequest.Size{Width: 3}, ErrOutputTooLarge},
{"width at the maximum", 4096, 1, Size{Height: 2}, nil},
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge},
{"height at the maximum", 1, 4096, Size{Width: 2}, nil},
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge},
}
for _, tt := range tests {
@@ -38,16 +32,9 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitCover,
})
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
if result != nil {
t.Logf("Process() made %dx%d", result.Width, result.Height)
@@ -77,21 +64,18 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
tests := []struct {
name string
inputWidth, inputHeight int
size imagerequest.Size
size Size
wantWidth, wantHeight int
}{
{"8192x1 at 100x0", 8192, 1, imagerequest.Size{Width: 100}, 100, 1},
{"8192x1 at 1300x0", 8192, 1, imagerequest.Size{Width: 1300}, 1300, 1},
{"8192x2 at 100x0", 8192, 2, imagerequest.Size{Width: 100}, 100, 1},
{"1x8192 at 0x100", 1, 8192, imagerequest.Size{Height: 100}, 1, 100},
{"1x8192 at 0x1300", 1, 8192, imagerequest.Size{Height: 1300}, 1, 1300},
{"2x8192 at 0x100", 2, 8192, imagerequest.Size{Height: 100}, 1, 100},
{"8192x1 at 100x0", 8192, 1, Size{Width: 100}, 100, 1},
{"8192x1 at 1300x0", 8192, 1, Size{Width: 1300}, 1300, 1},
{"8192x2 at 100x0", 8192, 2, Size{Width: 100}, 100, 1},
{"1x8192 at 0x100", 1, 8192, Size{Height: 100}, 1, 100},
{"1x8192 at 0x1300", 1, 8192, Size{Height: 1300}, 1, 1300},
{"2x8192 at 0x100", 2, 8192, Size{Height: 100}, 1, 100},
}
fits := []imagerequest.Fit{
imagerequest.FitCover, imagerequest.FitContain, imagerequest.FitFill,
imagerequest.FitInside, imagerequest.FitOutside,
}
fits := []FitMode{FitCover, FitContain, FitFill, FitInside, FitOutside}
for _, tt := range tests {
for _, fit := range fits {
@@ -107,16 +91,9 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: fit,
})
&Request{Size: tt.size, Format: FormatJPEG, FitMode: fit})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -126,14 +103,14 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
t.Logf("Process() made %dx%d", result.Width, result.Height)
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
if fit == imagerequest.FitOutside {
if fit == FitOutside {
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
}
if !ok {
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
imagerequest.FitOutside)
FitOutside)
}
})
}
@@ -84,9 +84,9 @@ func checkProcessStopsAtDeadline(
) {
t.Helper()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{MaxConcurrentProcessing: 1})
result, err := proc.Process(ctx, input, smallJPEGTransform())
result, err := proc.Process(ctx, input, smallJPEGRequest())
if result != nil {
t.Logf("Process() made a %dx%d image after its deadline passed",
result.Width, result.Height)
@@ -4,8 +4,6 @@ import (
"bytes"
"errors"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
@@ -14,12 +12,8 @@ import (
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
t.Parallel()
_, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
&imagerequest.Transform{},
_, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{},
)
if !errors.Is(err, ErrUnsupportedOutputFormat) {
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
@@ -8,20 +8,14 @@ import (
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// processedSize runs input through Process and returns the output's size in
// bytes.
func processedSize(
t *testing.T, input []byte, transform *imagerequest.Transform,
) int64 {
func processedSize(t *testing.T, input []byte, req *Request) int64 {
t.Helper()
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(), bytes.NewReader(input), transform)
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -74,8 +68,7 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
}
}
size := processedSize(t, encodePNG(t, flat),
&imagerequest.Transform{Format: imagerequest.FormatPNG})
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
const rawBytes = width * height * 3
if size > rawBytes/10 {
@@ -84,28 +77,27 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
}
}
// TestImageProcessor_WebPAtEffort2 verifies that WebP is saved at effort 2,
// not libvips' default of 4: the output is the size of the same image saved
// at effort 2.
func TestImageProcessor_WebPAtEffort2(t *testing.T) {
// TestImageProcessor_WebPAtDefaultEffort verifies that WebP is saved at
// libvips' default effort, 4: the output is the size of the same image saved
// at that effort.
func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 200, 150)
size := processedSize(t, input,
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
StripMetadata: true,
Quality: 85,
ReductionEffort: 2,
ReductionEffort: 4,
})
if err != nil {
t.Fatalf("ExportWebp() error = %v", err)
}
if size != int64(len(want)) {
t.Errorf("WebP output is %d bytes, want %d, the size at effort 2",
t.Errorf("WebP output is %d bytes, want %d, the size at effort 4",
size, len(want))
}
}
@@ -134,8 +126,7 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
input := encodePNG(t, source)
size := processedSize(t, input,
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
StripMetadata: true,
@@ -152,30 +143,3 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
"and 8 bits", size, len(want))
}
}
// TestImageProcessor_JPEGXLAtEffort4 verifies that JPEG XL is saved at
// effort 4, not libvips' default of 7: the output is the size of the same
// image saved at effort 4, with the distance exportJXL gives quality 85.
func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 200, 150)
size := processedSize(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
params := vips.NewJxlExportParams()
params.Quality = 85
params.Distance = jxlDistance(85)
params.Effort = 4
want, _, err := decode(t, input).ExportJxl(params)
if err != nil {
t.Fatalf("ExportJxl() error = %v", err)
}
if size != int64(len(want)) {
t.Errorf("JPEG XL output is %d bytes, want %d, the size at effort 4",
size, len(want))
}
}
@@ -7,8 +7,6 @@ import (
"image/png"
"math"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
@@ -22,12 +20,12 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
tests := []struct {
name string
inputWidth, inputHeight int
size imagerequest.Size
size Size
}{
{"8192x1 at 8192x8192", 8192, 1, imagerequest.Size{Width: 8192, Height: 8192}},
{"8192x1 at 1x8192", 8192, 1, imagerequest.Size{Width: 1, Height: 8192}},
{"1x8192 at 8192x8192", 1, 8192, imagerequest.Size{Width: 8192, Height: 8192}},
{"1x8192 at 8192x1", 1, 8192, imagerequest.Size{Width: 8192, Height: 1}},
{"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}},
{"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}},
{"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}},
{"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}},
}
for _, tt := range tests {
@@ -36,16 +34,9 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitCover,
})
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -109,15 +100,12 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatPNG,
FitMode: imagerequest.FitCover,
&Request{
Size: Size{Width: 50, Height: 50},
Format: FormatPNG,
FitMode: FitCover,
})
if err != nil {
t.Fatalf("Process() error = %v", err)
@@ -220,14 +208,14 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
tests := []struct {
name string
side, squareSide int
size imagerequest.Size
size Size
}{
{"16x16 at 100x60", 16, 8, imagerequest.Size{Width: 100, Height: 60}},
{"16x16 at 60x100", 16, 8, imagerequest.Size{Width: 60, Height: 100}},
{"10x10 at 100x35", 10, 2, imagerequest.Size{Width: 100, Height: 35}},
{"10x10 at 35x100", 10, 2, imagerequest.Size{Width: 35, Height: 100}},
{"64x64 at 300x100", 64, 16, imagerequest.Size{Width: 300, Height: 100}},
{"64x64 at 100x300", 64, 16, imagerequest.Size{Width: 100, Height: 300}},
{"16x16 at 100x60", 16, 8, Size{Width: 100, Height: 60}},
{"16x16 at 60x100", 16, 8, Size{Width: 60, Height: 100}},
{"10x10 at 100x35", 10, 2, Size{Width: 100, Height: 35}},
{"10x10 at 35x100", 10, 2, Size{Width: 35, Height: 100}},
{"64x64 at 300x100", 64, 16, Size{Width: 300, Height: 100}},
{"64x64 at 100x300", 64, 16, Size{Width: 100, Height: 300}},
}
for _, tt := range tests {
@@ -236,16 +224,9 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
input := createSquarePNG(t, tt.side, tt.squareSide)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatPNG,
FitMode: imagerequest.FitCover,
})
&Request{Size: tt.size, Format: FormatPNG, FitMode: FitCover})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -4,8 +4,6 @@ import (
"bytes"
"errors"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
@@ -19,20 +17,15 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
tests := []struct {
name string
inputWidth, inputHeight int
size imagerequest.Size
size Size
// wantWidth and wantHeight are the output's, 0 for a refusal
wantWidth, wantHeight int
}{
{"width above the maximum", 3000, 600,
imagerequest.Size{Width: 1, Height: 8192}, 0, 0},
{"height above the maximum", 600, 3000,
imagerequest.Size{Width: 8192, Height: 1}, 0, 0},
{"width at the maximum", 1024, 256,
imagerequest.Size{Width: 1, Height: 2048}, 8192, 2048},
{"height at the maximum", 256, 1024,
imagerequest.Size{Width: 2048, Height: 1}, 2048, 8192},
{"within the maximum", 3000, 600,
imagerequest.Size{Width: 1, Height: 1200}, 6000, 1200},
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0},
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0},
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048},
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192},
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200},
}
for _, tt := range tests {
@@ -41,16 +34,9 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitOutside,
})
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
if result != nil {
t.Logf("Process() made %dx%d", result.Width, result.Height)
}
@@ -85,11 +71,11 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
tests := []struct {
name string
inputWidth, inputHeight int
size imagerequest.Size
size Size
wantWidth, wantHeight int
}{
{"49x8192 at 0x100", 49, 8192, imagerequest.Size{Height: 100}, 1, 167},
{"49x49 at 1x1", 49, 49, imagerequest.Size{Width: 1, Height: 1}, 1, 1},
{"49x8192 at 0x100", 49, 8192, Size{Height: 100}, 1, 167},
{"49x49 at 1x1", 49, 49, Size{Width: 1, Height: 1}, 1, 1},
}
for _, tt := range tests {
@@ -98,16 +84,9 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitOutside,
})
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
+208 -197
View File
@@ -11,7 +11,7 @@ import (
"time"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/config"
)
// vipsOnce ensures vips is initialized exactly once.
@@ -54,6 +54,49 @@ func CheckJPEGXLSupport() error {
return nil
}
// Format represents supported output image formats.
type Format string
// Supported image output formats.
const (
FormatOriginal Format = "orig"
FormatJPEG Format = "jpeg"
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif"
)
// FitMode represents how to fit an image into requested dimensions.
type FitMode string
// Supported image fit modes.
const (
FitCover FitMode = "cover"
FitContain FitMode = "contain"
FitFill FitMode = "fill"
FitInside FitMode = "inside"
FitOutside FitMode = "outside"
)
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// Size represents requested image dimensions.
type Size struct {
Width int
Height int
}
// Request holds the parameters for image processing.
type Request struct {
Size Size
Format Format
Quality int
FitMode FitMode
}
// Result contains the output of image processing.
type Result struct {
// Content is the processed image data.
@@ -78,6 +121,10 @@ type Result struct {
// Images larger than this are rejected to prevent DoS via decompression bombs.
const MaxInputDimension = 8192
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
// This matches the default upstream fetcher limit.
const DefaultMaxInputBytes = 50 << 20
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
@@ -86,8 +133,8 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// MaxOutputDimension is the maximum width or height of an output image, and
// so of a requested size (imagerequest.MaxDimension).
const MaxOutputDimension = imagerequest.MaxDimension
// so of a requested size (imgcache.MaxDimension).
const MaxOutputDimension = 8192
// ErrOutputTooLarge is returned, before resizing, when the output would have a
// side above MaxOutputDimension: the side of a size with one side 0, computed
@@ -95,19 +142,6 @@ const MaxOutputDimension = imagerequest.MaxDimension
// requested size.
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
// MaxAVIFOutputPixels is the most pixels an AVIF output may have, so that an
// encode takes at most 40 seconds of the default downstream_timeout of 60
// seconds; AVIF is already at the lowest effort govips can set (avifEffort).
// With one thread, as pixad runs libvips, saving 4096x4096, 8192x2048 or
// 2048x8192 random pixels with an alpha channel takes up to about 34
// seconds, the slowest at any quality and at 8 or 16 bits per sample, and
// 5120x4096 at 8 bits already takes about 36.
const MaxAVIFOutputPixels = 4096 * 4096
// ErrAVIFOutputTooLarge is returned, before anything is decoded, when an AVIF
// output would have more than MaxAVIFOutputPixels pixels.
var ErrAVIFOutputTooLarge = errors.New("AVIF output has too many pixels")
// ErrUnsupportedOutputFormat is returned when the requested output format is
// not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
@@ -137,38 +171,50 @@ type ImageProcessor struct {
processingWaitTimeout time.Duration
}
// Params holds configuration for creating an ImageProcessor. Both fields
// must be at least 1.
// Params holds configuration for creating an ImageProcessor.
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
type Params struct {
// MaxInputBytes is the maximum allowed input size in bytes.
// If <= 0, DefaultMaxInputBytes is used.
MaxInputBytes int64
// MaxConcurrentProcessing is the most images processed at once.
// If <= 0, config.DefaultMaxConcurrentProcessing() is used.
MaxConcurrentProcessing int
}
// New creates a new image processor with the given parameters.
// A zero-value Params{} uses sensible defaults.
func New(params Params) *ImageProcessor {
initVips()
maxInputBytes := params.MaxInputBytes
if maxInputBytes <= 0 {
maxInputBytes = DefaultMaxInputBytes
}
maxConcurrentProcessing := params.MaxConcurrentProcessing
if maxConcurrentProcessing <= 0 {
maxConcurrentProcessing = config.DefaultMaxConcurrentProcessing()
}
return &ImageProcessor{
maxInputBytes: params.MaxInputBytes,
processingSemaphore: make(chan struct{}, params.MaxConcurrentProcessing),
maxInputBytes: maxInputBytes,
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
processingWaitTimeout: ProcessingWaitTimeout,
}
}
// Process transforms an image according to transform. When
// Process transforms an image according to the request. When
// MaxConcurrentProcessing images are already being processed, it waits up
// to ProcessingWaitTimeout for one to finish, then fails with
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
// no way to stop libvips once an encode has begun, so an encode keeps its
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
// refused with ErrAVIFOutputTooLarge before anything is decoded.
// slot until it finishes.
func (p *ImageProcessor) Process(
ctx context.Context,
input io.Reader,
transform *imagerequest.Transform,
req *Request,
) (*Result, error) {
release, err := p.acquireSlot(ctx)
if err != nil {
@@ -177,9 +223,18 @@ func (p *ImageProcessor) Process(
defer release()
data, err := p.readInput(input)
// Read input with a size limit to prevent unbounded memory consumption.
// We read at most maxInputBytes+1 so we can detect if the input exceeds
// the limit without consuming additional memory.
limited := io.LimitReader(input, p.maxInputBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read input: %w", err)
}
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
// govips picks the libvips loader from the input's first bytes with
@@ -217,36 +272,27 @@ func (p *ImageProcessor) Process(
}
// Determine target dimensions; too large ones are refused before resizing
targetWidth, targetHeight, err := targetDimensions(transform.Size,
origWidth, origHeight)
if err != nil {
return nil, err
}
// orig is the source's own format; encode refuses an empty format
outputFormat := transform.Format
if outputFormat == imagerequest.FormatOriginal {
outputFormat = sourceFormat
}
// Determine output dimensions; too large ones are refused before
// resizing, which with FitCover already decodes the input
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
targetWidth, targetHeight, transform.FitMode, outputFormat)
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
if err != nil {
return nil, err
}
// Resize if needed
if targetWidth != origWidth || targetHeight != origHeight {
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
if err != nil {
return nil, fmt.Errorf("failed to resize: %w", err)
}
}
// orig is the source's own format; encode refuses an empty format
outputFormat := req.Format
if outputFormat == FormatOriginal {
outputFormat = sourceFormat
}
// Encode to target format
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
output, err := p.encode(ctx, img, outputFormat, req.Quality)
if err != nil {
return nil, fmt.Errorf("failed to encode: %w", err)
}
@@ -269,9 +315,7 @@ func (p *ImageProcessor) Process(
// rounded down but kept at 1 or more, as for a thin image it would round
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
// which is ErrOutputTooLarge.
func targetDimensions(
size imagerequest.Size, origWidth, origHeight int,
) (int, int, error) {
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
width, height := size.Width, size.Height
switch {
@@ -304,22 +348,46 @@ const (
mimeJXL = "image/jxl"
)
// SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{
mimeJPEG,
mimePNG,
mimeGIF,
mimeWebP,
mimeAVIF,
mimeJXL,
}
}
// SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []Format {
return []Format{
FormatJPEG,
FormatPNG,
FormatGIF,
FormatWebP,
FormatAVIF,
FormatJXL,
}
}
// FormatToMIME converts a Format to its MIME type string.
func FormatToMIME(format imagerequest.Format) string {
func FormatToMIME(format Format) string {
switch format {
case imagerequest.FormatJPEG:
case FormatJPEG:
return mimeJPEG
case imagerequest.FormatPNG:
case FormatPNG:
return mimePNG
case imagerequest.FormatWebP:
case FormatWebP:
return mimeWebP
case imagerequest.FormatGIF:
case FormatGIF:
return mimeGIF
case imagerequest.FormatAVIF:
case FormatAVIF:
return mimeAVIF
case imagerequest.FormatJXL:
case FormatJXL:
return mimeJXL
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
case FormatOriginal:
return "application/octet-stream"
default:
return "application/octet-stream"
@@ -377,23 +445,6 @@ func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
return release, nil
}
// readInput reads input with a size limit to prevent unbounded memory
// consumption, refusing more than maxInputBytes with ErrInputDataTooLarge. It
// reads at most maxInputBytes+1 so it can detect if the input exceeds the
// limit without consuming additional memory.
func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(input, p.maxInputBytes+1))
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
return data, nil
}
// detectFormat returns the format string for a govips image type.
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
switch imageType {
@@ -406,9 +457,9 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
case vips.ImageTypeWEBP:
return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return string(imagerequest.FormatAVIF)
return string(FormatAVIF)
case vips.ImageTypeJXL:
return string(imagerequest.FormatJXL)
return string(FormatJXL)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K:
@@ -418,94 +469,72 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
}
}
// outputDimensions returns the dimensions resize gives an image of imgWidth x
// imgHeight for the target dimensions and fit mode, without resizing it, and
// refuses an output too large for format. With FitOutside one side can come
// out larger than the target; when it would be above MaxOutputDimension,
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
func outputDimensions(
imgWidth, imgHeight, width, height int,
fit imagerequest.Fit, format imagerequest.Format,
) (int, int, error) {
scaleW := float64(width) / float64(imgWidth)
scaleH := float64(height) / float64(imgHeight)
outputWidth, outputHeight := width, height
switch fit {
case imagerequest.FitCover, imagerequest.FitFill, "":
// Cover crops, and fill stretches, to exactly the target dimensions
case imagerequest.FitContain:
// Fit within the target dimensions, maintaining aspect ratio. Scaled
// by the smaller scale, the short side of a thin image can round
// down to 0, which libvips refuses as a width, so each side is at
// least 1.
scale := min(scaleW, scaleH)
outputWidth = max(1, int(float64(imgWidth)*scale))
outputHeight = max(1, int(float64(imgHeight)*scale))
case imagerequest.FitInside:
// Same as contain, but only shrink
scale := min(scaleW, scaleH, 1)
outputWidth = max(1, int(float64(imgWidth)*scale))
outputHeight = max(1, int(float64(imgHeight)*scale))
case imagerequest.FitOutside:
// The smallest dimension fits, the other may exceed the target. A
// side scaled to 1 can come out just under 1 in floating point, as
// 49 times 1/49 does, and round down to 0, which libvips refuses as
// a width, so each side is at least 1.
scale := max(scaleW, scaleH)
outputWidth = max(1, int(float64(imgWidth)*scale))
outputHeight = max(1, int(float64(imgHeight)*scale))
default:
return 0, 0, fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
}
// The side FitOutside scales past the target is held to the maximum too
if max(outputWidth, outputHeight) > MaxOutputDimension {
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
outputWidth, outputHeight, MaxOutputDimension)
}
if format == imagerequest.FormatAVIF &&
outputWidth*outputHeight > MaxAVIFOutputPixels {
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
}
return outputWidth, outputHeight, nil
}
// resize resizes the image to width x height, the dimensions
// outputDimensions gives for the fit mode.
// resize resizes the image according to the fit mode. With FitOutside one
// side can come out larger than requested; when it would be above
// MaxOutputDimension, resize returns ErrOutputTooLarge without resizing.
func (p *ImageProcessor) resize(
img *vips.ImageRef, width, height int, fit imagerequest.Fit,
img *vips.ImageRef, width, height int, fit FitMode,
) error {
switch fit {
case imagerequest.FitCover, "":
case FitCover, "":
// Crop the centre and resize to fill exact dimensions (default)
return cropCentreAndResize(img, width, height)
case imagerequest.FitFill:
case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio. Scaled
// by the smaller scale, the short side of a thin image can round
// down to 0, which libvips refuses as a width, so each side is at
// least 1.
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
scale := min(scaleW, scaleH)
newW := max(1, int(float64(imgW)*scale))
newH := max(1, int(float64(imgH)*scale))
return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitFill:
// Resize to exact dimensions (may distort)
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case imagerequest.FitInside:
// outputDimensions keeps the dimensions of an image that already fits
if img.Width() == width && img.Height() == height {
return nil
case FitInside:
// Same as contain, but only shrink
if img.Width() <= width && img.Height() <= height {
return nil // Already fits
}
return img.Thumbnail(width, height, vips.InterestingNone)
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
scale := min(scaleW, scaleH)
newW := max(1, int(float64(imgW)*scale))
newH := max(1, int(float64(imgH)*scale))
case imagerequest.FitContain, imagerequest.FitOutside:
return img.Thumbnail(width, height, vips.InterestingNone)
return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitOutside:
// Resize so smallest dimension fits, may exceed target on other
// dimension. A side scaled to 1 can come out just under 1 in
// floating point, as 49 times 1/49 does, and round down to 0, which
// libvips refuses as a width, so each side is at least 1.
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
scale := max(scaleW, scaleH)
newW := max(1, int(float64(imgW)*scale))
newH := max(1, int(float64(imgH)*scale))
// The dimension that exceeds the target is held to the maximum too
if newW > MaxOutputDimension || newH > MaxOutputDimension {
return fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
newW, newH, MaxOutputDimension)
}
return img.Thumbnail(newW, newH, vips.InterestingNone)
default:
return fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
}
}
@@ -547,8 +576,7 @@ const defaultQuality = 85
// to stop libvips once an encode has begun, so when ctx has ended encode
// returns ctx's error without beginning one.
func (p *ImageProcessor) encode(
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
quality int,
ctx context.Context, img *vips.ImageRef, format Format, quality int,
) ([]byte, error) {
err := ctx.Err()
if err != nil {
@@ -571,25 +599,25 @@ func (p *ImageProcessor) encode(
}
switch format {
case imagerequest.FormatJPEG:
case FormatJPEG:
return exportJPEG(img, quality)
case imagerequest.FormatPNG:
case FormatPNG:
return exportPNG(img)
case imagerequest.FormatGIF:
case FormatGIF:
return exportGIF(img)
case imagerequest.FormatWebP:
case FormatWebP:
return exportWebP(img, quality)
case imagerequest.FormatAVIF:
case FormatAVIF:
return exportAVIF(img, quality)
case imagerequest.FormatJXL:
case FormatJXL:
return exportJXL(img, quality)
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
case FormatOriginal:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
default:
@@ -639,16 +667,11 @@ func exportGIF(img *vips.ImageRef) ([]byte, error) {
return output, err
}
// webpEffort is the WebP effort, from 0 (fastest) to 6. It is below libvips'
// default of 4 so that an encode takes at most 40 seconds of the default
// downstream_timeout of 60 seconds. With one thread, as pixad runs libvips,
// saving an 8192x8192 image of random pixels with an alpha channel takes up
// to about 23 seconds at 2, and up to about 16 without one, at any quality;
// with an alpha channel, 3 takes about 50 seconds and 4 about 61.
const webpEffort = 2
// webpEffort is libvips' default WebP effort, from 0 (fastest) to 6.
const webpEffort = 4
// exportWebP encodes img as lossy WebP at quality and webpEffort, without
// metadata.
// exportWebP encodes img as lossy WebP at quality and libvips' default
// effort, without metadata.
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
output, _, err := img.ExportWebp(&vips.WebpExportParams{
StripMetadata: true,
@@ -660,13 +683,12 @@ func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
}
// avifEffort is the AVIF effort, from 0 (fastest) to 9; 1 is the lowest
// govips can set. With one thread, as pixad runs libvips, 1 takes about 52
// seconds to save an 8192x8192 RGB image of random pixels at quality 85,
// and about 96 with an alpha channel, against the default
// downstream_timeout of 60 seconds, so an AVIF output is held to
// MaxAVIFOutputPixels. On an image of milder noise, which 1 saves in about
// 12 seconds, 2 takes nearly a minute and libvips' default, 4, takes
// minutes.
// govips can set. With one thread, as pixad runs libvips, 1 takes about 51
// seconds to save an 8192x8192 RGB image of random pixels, and about 110
// with an alpha channel, the worst case, against the default
// downstream_timeout of 60 seconds. On an image of milder noise, which 1
// saves in about 12 seconds, 2 takes nearly a minute and libvips' default,
// 4, takes minutes.
const avifEffort = 1
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
@@ -693,18 +715,10 @@ func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
// JPEG that names none.
const jxlResolution = 72 / 25.4
// jxlEffort is the JPEG XL effort, from 1 (fastest) to 9. It is below
// libvips' default of 7 so that an encode takes at most 40 seconds of the
// default downstream_timeout of 60 seconds. With one thread, as pixad runs
// libvips, saving an 8192x8192 image of random pixels with an alpha channel
// takes up to about 27 seconds at 4, and up to about 13 without one, at any
// quality; with an alpha channel, 5 takes about 43 seconds and 7 about 81.
const jxlEffort = 4
// exportJXL encodes img as JPEG XL at quality and jxlEffort, without
// metadata. govips sends libvips a distance, the JPEG XL encoder's own
// measure of quality, along with the quality, and libvips then uses the
// distance alone, so the quality is also given as a distance.
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
// and without metadata. govips sends libvips a distance, the JPEG XL
// encoder's own measure of quality, along with the quality, and libvips then
// uses the distance alone, so the quality is also given as a distance.
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
// PNG, but cannot save one as JPEG XL. encode has already converted
@@ -743,7 +757,6 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
params := vips.NewJxlExportParams()
params.Quality = quality
params.Distance = jxlDistance(quality)
params.Effort = jxlEffort
output, _, err := toSave.ExportJxl(params)
if err != nil {
@@ -771,22 +784,20 @@ func jxlDistance(quality int) float64 {
// formatFromString converts a format string from detectFormat to Format. A
// format detectFormat could not name is ErrUnsupportedInputFormat.
func (p *ImageProcessor) formatFromString(
format string,
) (imagerequest.Format, error) {
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
switch format {
case "jpeg":
return imagerequest.FormatJPEG, nil
return FormatJPEG, nil
case "png":
return imagerequest.FormatPNG, nil
return FormatPNG, nil
case "gif":
return imagerequest.FormatGIF, nil
return FormatGIF, nil
case "webp":
return imagerequest.FormatWebP, nil
case string(imagerequest.FormatAVIF):
return imagerequest.FormatAVIF, nil
case string(imagerequest.FormatJXL):
return imagerequest.FormatJXL, nil
return FormatWebP, nil
case string(FormatAVIF):
return FormatAVIF, nil
case string(FormatJXL):
return FormatJXL, nil
default:
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
}
@@ -15,7 +15,6 @@ import (
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
)
func TestMain(m *testing.M) {
@@ -88,7 +87,7 @@ func isAVIF(data []byte) bool {
brand := string(data[8:12])
return brand == string(imagerequest.FormatAVIF) || brand == "avis"
return brand == string(FormatAVIF) || brand == "avis"
}
// detectMIME is a minimal magic-byte detector for test assertions.
@@ -119,19 +118,19 @@ func detectMIME(data []byte) string {
func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 800, 600)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 400, Height: 300},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 400, Height: 300},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -165,18 +164,18 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 200, Height: 150},
Format: imagerequest.FormatPNG,
FitMode: imagerequest.FitCover,
req := &Request{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -197,23 +196,23 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
// processAndCheckSize processes a test JPEG of the given input dimensions
// with the requested size and asserts the resulting dimensions.
func processAndCheckSize(
t *testing.T, inputW, inputH int, size imagerequest.Size, wantW, wantH int,
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
) {
t.Helper()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, inputW, inputH)
transform := &imagerequest.Transform{
req := &Request{
Size: size,
Format: imagerequest.FormatJPEG,
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -233,27 +232,27 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
t.Parallel()
// Width and height 0: keep original size
processAndCheckSize(t, 640, 480, imagerequest.Size{Width: 0, Height: 0}, 640, 480)
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
}
func TestImageProcessor_FitContain(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
// 800x400 image (2:1 aspect) into 400x400 box with contain
// Should result in 400x200 (maintaining aspect ratio)
input := createTestJPEG(t, 800, 400)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 400, Height: 400},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 400, Height: 400},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitContain,
FitMode: FitContain,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -271,7 +270,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
// 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 400, Height: 0}, 400, 300)
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
}
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
@@ -279,24 +278,24 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
// 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 0, Height: 300}, 400, 300)
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
}
func TestImageProcessor_ProcessPNG(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
input := createTestPNG(t, 400, 300)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 200, Height: 150},
Format: imagerequest.FormatPNG,
FitMode: imagerequest.FitCover,
req := &Request{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
@@ -312,6 +311,22 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
}
}
func TestImageProcessor_SupportedFormats(t *testing.T) {
t.Parallel()
proc := New(Params{})
inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 {
t.Error("SupportedInputFormats() returned empty slice")
}
outputFormats := proc.SupportedOutputFormats()
if len(outputFormats) == 0 {
t.Error("SupportedOutputFormats() returned empty slice")
}
}
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
t.Parallel()
@@ -330,18 +345,18 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, tt.width, tt.height)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 100},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
@@ -356,20 +371,20 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
// Create an image at exactly MaxInputDimension - should be accepted
input := createTestJPEG(t, MaxInputDimension, 100)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 100},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf(
"Process() should accept images at MaxInputDimension, got error: %v",
@@ -382,24 +397,22 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
// given format and asserts the output MIME type and dimensions.
func encodeAndCheck(
t *testing.T, format imagerequest.Format, quality int, wantMIME string,
) {
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
t.Helper()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 75},
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: format,
Quality: quality,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v, want nil", err)
}
@@ -430,13 +443,13 @@ func encodeAndCheck(
func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Parallel()
encodeAndCheck(t, imagerequest.FormatWebP, 80, mimeWebP)
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
}
func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
ctx := context.Background()
// Load test AVIF file
@@ -446,14 +459,14 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
}
// Request resize and convert to JPEG
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 2, Height: 2},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 2, Height: 2},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
}
@@ -478,7 +491,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
// Create a processor with a very small byte limit
const limit = 1024
proc := New(Params{MaxInputBytes: limit, MaxConcurrentProcessing: 1})
proc := New(Params{MaxInputBytes: limit})
ctx := context.Background()
// Create a valid JPEG that exceeds the byte limit
@@ -487,14 +500,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
}
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 75},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Fatal("Process() should reject input exceeding maxInputBytes")
}
@@ -511,17 +524,17 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
input := createTestJPEG(t, 10, 10)
limit := int64(len(input)) * 10 // 10× headroom
proc := New(Params{MaxInputBytes: limit, MaxConcurrentProcessing: 1})
proc := New(Params{MaxInputBytes: limit})
ctx := context.Background()
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 10, Height: 10},
Format: imagerequest.FormatJPEG,
req := &Request{
Size: Size{Width: 10, Height: 10},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v, want nil", err)
}
@@ -529,24 +542,35 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
defer func() { _ = result.Content.Close() }()
}
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
t.Parallel()
// Passing 0 should use the default
proc := New(Params{})
if proc.maxInputBytes != DefaultMaxInputBytes {
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
}
// Passing negative should also use the default
proc = New(Params{MaxInputBytes: -1})
if proc.maxInputBytes != DefaultMaxInputBytes {
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
}
}
func TestImageProcessor_EncodeAVIF(t *testing.T) {
t.Parallel()
encodeAndCheck(t, imagerequest.FormatAVIF, 85, mimeAVIF)
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
}
// processAndDecode runs input through Process and decodes the output with
// vips, so a test can inspect the image a client would receive.
func processAndDecode(
t *testing.T, input []byte, transform *imagerequest.Transform,
) *vips.ImageRef {
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
t.Helper()
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(
context.Background(), bytes.NewReader(input), transform,
result, err := New(Params{}).Process(
context.Background(), bytes.NewReader(input), req,
)
if err != nil {
t.Fatalf("Process() error = %v", err)
@@ -590,17 +614,15 @@ func TestImageProcessor_StripsEXIF(t *testing.T) {
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
}
formats := []imagerequest.Format{
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatWebP,
imagerequest.FormatAVIF, imagerequest.FormatGIF, imagerequest.FormatOriginal,
formats := []Format{
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
}
for _, format := range formats {
t.Run(string(format), func(t *testing.T) {
t.Parallel()
output := processAndDecode(t, input,
&imagerequest.Transform{Format: format})
output := processAndDecode(t, input, &Request{Format: format})
if output.HasExif() {
t.Errorf("output has EXIF: %v", output.GetExif())
@@ -622,21 +644,21 @@ func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
tests := []struct {
name string
size imagerequest.Size
size Size
wantW int
wantH int
}{
{name: "original size", size: imagerequest.Size{}, wantW: 8, wantH: 16},
{name: "width only", size: imagerequest.Size{Width: 4}, wantW: 4, wantH: 8},
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
output := processAndDecode(t, input, &imagerequest.Transform{
output := processAndDecode(t, input, &Request{
Size: tt.size,
Format: imagerequest.FormatPNG,
Format: FormatPNG,
})
if output.Width() != tt.wantW || output.Height() != tt.wantH {
@@ -672,8 +694,7 @@ func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatPNG})
output := processAndDecode(t, input, &Request{Format: FormatPNG})
if output.HasICCProfile() {
t.Error("output has an ICC profile")
@@ -8,7 +8,6 @@ import (
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
@@ -16,16 +15,10 @@ import (
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
t.Parallel()
transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 75},
Format: imagerequest.FormatJXL,
}
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
)
if err != nil {
t.Fatalf("Process() error = %v", err)
@@ -67,12 +60,8 @@ func TestImageProcessor_JPEGXLQuality(t *testing.T) {
outputBytes := make(map[int]int64)
for _, quality := range []int{30, 90} {
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(t.Context(),
bytes.NewReader(input),
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
if err != nil {
t.Fatalf("Process() at quality %d error = %v", quality, err)
}
@@ -100,8 +89,7 @@ func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err)
}
exif := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL}).GetExif()
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
for _, field := range []string{
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
@@ -127,8 +115,7 @@ func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
output := processAndDecode(t, input, &Request{Format: FormatJXL})
// libvips gives the resolution in pixels per millimetre.
xDPI := math.Round(output.ResX() * 25.4)
@@ -153,8 +140,7 @@ func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
output := processAndDecode(t, input, &Request{Format: FormatJXL})
if output.Width() != 8 || output.Height() != 16 {
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
@@ -190,8 +176,7 @@ func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
output := processAndDecode(t, input, &Request{Format: FormatJXL})
pixel, err := output.GetPoint(4, 4)
if err != nil {
@@ -218,8 +203,7 @@ func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
output := processAndDecode(t, input, &Request{Format: FormatJXL})
if output.Bands() != 3 {
t.Fatalf("output has %d bands, want 3", output.Bands())
@@ -5,13 +5,12 @@ import (
"context"
"errors"
"io"
"runtime"
"strings"
"sync"
"testing"
"testing/iotest"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// errTestReadFailed is the error the unreadable test input returns.
@@ -77,13 +76,13 @@ func (r *gatedReader) Read(p []byte) (int, error) {
return n, err
}
// smallJPEGTransform asks for a 5x5 JPEG.
func smallJPEGTransform() *imagerequest.Transform {
return &imagerequest.Transform{
Size: imagerequest.Size{Width: 5, Height: 5},
Format: imagerequest.FormatJPEG,
// smallJPEGRequest asks for a 5x5 JPEG.
func smallJPEGRequest() *Request {
return &Request{
Size: Size{Width: 5, Height: 5},
Format: FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
FitMode: FitCover,
}
}
@@ -93,7 +92,7 @@ func processInBackground(
proc *ImageProcessor, reader *gatedReader, results chan<- error,
) {
go func() {
result, err := proc.Process(context.Background(), reader, smallJPEGTransform())
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
if err == nil {
_ = result.Content.Close()
}
@@ -116,15 +115,47 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
}
}
func TestNewMakesMaxConcurrentProcessingSlots(t *testing.T) {
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 3})
for _, limit := range []int{0, -1} {
proc := New(Params{MaxConcurrentProcessing: limit})
if got := cap(proc.processingSemaphore); got != max(runtime.GOMAXPROCS(0)/2, 1) {
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, half the CPUs",
limit, got, max(runtime.GOMAXPROCS(0)/2, 1))
}
}
proc := New(Params{MaxConcurrentProcessing: 3})
if got := cap(proc.processingSemaphore); got != 3 {
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
}
}
// TestNewDefaultIsHalfTheCPUsAndAtLeastOne checks that New, given no
// MaxConcurrentProcessing, makes one slot for every two CPUs Go uses,
// rounded down, and one slot when Go uses one CPU.
//
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
func TestNewDefaultIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
cpusBefore := runtime.GOMAXPROCS(0)
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
for _, tc := range []struct{ cpus, want int }{
{1, 1}, {2, 1}, {3, 1}, {8, 4},
} {
runtime.GOMAXPROCS(tc.cpus)
got := cap(New(Params{}).processingSemaphore)
t.Logf("GOMAXPROCS %d: %d slots", tc.cpus, got)
if got != tc.want {
t.Errorf("GOMAXPROCS %d: %d slots, want %d", tc.cpus, got, tc.want)
}
}
}
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
// than MaxConcurrentProcessing allows and holds the first ones inside
// Process until the test lets them go. No more than the limit may be
@@ -138,7 +169,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
calls = 6
)
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: limit})
proc := New(Params{MaxConcurrentProcessing: limit})
input := createTestJPEG(t, 50, 50)
counter := &readingCounter{}
@@ -185,7 +216,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{MaxConcurrentProcessing: 1})
proc.processingWaitTimeout = 100 * time.Millisecond
input := createTestJPEG(t, 10, 10)
@@ -206,7 +237,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
start := time.Now()
_, err := proc.Process(context.Background(), bytes.NewReader(input),
smallJPEGTransform())
smallJPEGRequest())
if !errors.Is(err, ErrTooManyImages) {
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
}
@@ -232,38 +263,38 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
valid := createTestJPEG(t, 10, 10)
unsupported := smallJPEGTransform()
unsupported := smallJPEGRequest()
unsupported.Format = "bmp"
cases := []struct {
name string
input io.Reader
transform *imagerequest.Transform
name string
input io.Reader
req *Request
// want is the error Process must return; nil means any error.
want error
}{
{
name: "input cannot be read",
input: iotest.ErrReader(errTestReadFailed),
transform: smallJPEGTransform(),
want: errTestReadFailed,
name: "input cannot be read",
input: iotest.ErrReader(errTestReadFailed),
req: smallJPEGRequest(),
want: errTestReadFailed,
},
{
name: "input over the byte limit",
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
transform: smallJPEGTransform(),
want: ErrInputDataTooLarge,
name: "input over the byte limit",
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
req: smallJPEGRequest(),
want: ErrInputDataTooLarge,
},
{
name: "input not an image",
input: strings.NewReader("not an image"),
transform: smallJPEGTransform(),
name: "input not an image",
input: strings.NewReader("not an image"),
req: smallJPEGRequest(),
},
{
name: "output format not supported",
input: bytes.NewReader(valid),
transform: unsupported,
want: ErrUnsupportedOutputFormat,
name: "output format not supported",
input: bytes.NewReader(valid),
req: unsupported,
want: ErrUnsupportedOutputFormat,
},
}
@@ -274,7 +305,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
proc.processingWaitTimeout = 100 * time.Millisecond
_, err := proc.Process(context.Background(), tc.input, tc.transform)
_, err := proc.Process(context.Background(), tc.input, tc.req)
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
t.Fatalf("Process() error = %v, want %v", err, tc.want)
}
@@ -284,7 +315,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
}
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
smallJPEGTransform())
smallJPEGRequest())
if err != nil {
t.Fatalf("Process() after the error = %v, want nil", err)
}
@@ -301,7 +332,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
func TestWaitForProcessing(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 2})
proc := New(Params{MaxConcurrentProcessing: 2})
gate := make(chan struct{})
entered := make(chan struct{}, 1)
@@ -6,7 +6,6 @@ import (
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
@@ -27,12 +26,8 @@ func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
t.Fatalf("failed to save test TIFF: %v", err)
}
result, err := New(Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
}).Process(
t.Context(), bytes.NewReader(tiff),
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal},
)
if result != nil {
t.Logf("Process() returned %s", result.ContentType)
@@ -4,8 +4,6 @@ import (
"bytes"
"io"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
@@ -14,15 +12,13 @@ import (
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
proc := New(Params{})
for _, format := range []imagerequest.Format{
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
for _, format := range []Format{
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
} {
saved, err := proc.Process(t.Context(),
bytes.NewReader(createTestPNG(t, 20, 20)),
&imagerequest.Transform{Format: format})
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
if err != nil {
t.Fatalf("saving the test image as %s: %v", format, err)
}
@@ -33,7 +29,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
}
result, err := proc.Process(t.Context(), bytes.NewReader(source),
&imagerequest.Transform{Format: imagerequest.FormatOriginal})
&Request{Format: FormatOriginal})
if err != nil {
t.Errorf("orig of the %s image: %v", format, err)
@@ -1,100 +0,0 @@
package imageproxy
import (
"bytes"
"os"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestProxy_Get_MakesAgainVariantOfNoOutputFormat replaces a cached
// variant's file with text, and checks that the next request is a miss
// answered with the variant made again, and the one after a hit.
func TestProxy_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
req := photoVariant(fixtures, 85, imagerequest.FitCover)
want, _ := getImage(t, svc, req)
path := variantFilePath(svc, imagerequest.CacheKey(req))
err := os.WriteFile(path, []byte("this is text, not an image"),
imgcache.StorageFilePerm)
if err != nil {
t.Fatalf("replacing the variant file: %v", err)
}
got, status := getImage(t, svc, req)
t.Logf("after the replacement: %d bytes, %s", len(got), status)
if status != CacheMiss || !bytes.Equal(got, want) {
t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+
"made again, %s", len(got), status, len(want), CacheMiss)
}
got, status = getImage(t, svc, req)
if status != CacheHit || !bytes.Equal(got, want) {
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
len(got), status, len(want), CacheHit)
}
}
// TestProxy_Get_HitWaitsForNoDatabaseWrite holds the database's one
// connection while a cached variant is requested. The hit must be answered
// while the connection is still held, and its access time must reach the
// database once it is free.
func TestProxy_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
req := photoVariant(fixtures, 85, imagerequest.FitCover)
key := imagerequest.CacheKey(req)
getImage(t, svc, req)
// Each connection to an in-memory SQLite database opens a new, empty one,
// so the goroutine that writes the access times must share the
// connection that holds the schema.
svc.db.SetMaxOpenConns(1)
svc.cache.StartPendingAccessTimeWrites()
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
anHourAgo := time.Now().Add(-time.Hour)
setVariantLastAccessed(t, svc.db, key, anHourAgo)
releaseDatabase := holdDatabase(t, svc.db)
defer releaseDatabase()
results := startGet(t.Context(), svc, req)
select {
case got := <-results:
if got.err != nil {
t.Fatalf("Get() of the cached variant error = %v", got.err)
}
case <-time.After(pendingAccessTimesWait):
t.Fatal("the cache hit waited for the database")
}
releaseDatabase()
deadline := time.Now().Add(pendingAccessTimesWait)
for countRows(t, svc.db, `
SELECT COUNT(*) FROM variant_content
WHERE cache_key = ? AND last_accessed_at > ?
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
if time.Now().After(deadline) {
t.Fatal("the hit's access time was not written")
}
time.Sleep(10 * time.Millisecond)
}
}
@@ -1,78 +0,0 @@
package imageproxy
import (
"errors"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestNewProxy_RegistersItsCountersOnItsRegistry checks that NewProxy
// registers the proxy's counters on the registry it is given, under the
// names README.md gives them. The cache registers its own counters on
// another registry.
func TestNewProxy_RegistersItsCountersOnItsRegistry(t *testing.T) {
t.Parallel()
cache, err := imgcache.NewCache(setupTestDB(t),
imgcache.Config{StateDir: t.TempDir(), MaxBytes: 1 << 30},
prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
registry := prometheus.NewRegistry()
_, err = NewProxy(&Config{Cache: cache}, registry)
if err != nil {
t.Fatalf("NewProxy() error = %v", err)
}
names := []string{
"pixa_upstream_fetches_total",
"pixa_upstream_fetch_bytes_total",
"pixa_transforms_total",
}
count, err := testutil.GatherAndCount(registry, names...)
if err != nil {
t.Fatalf("testutil.GatherAndCount() error = %v", err)
}
if count != len(names) {
t.Errorf("%d of the counters %v are on the registry, want %d",
count, names, len(names))
}
}
// TestNewProxy_FailsWhenItsCountersCannotBeRegistered makes a second proxy
// on the registry of a first. Its counters have the same names, so the
// registry refuses them, and NewProxy must return that error.
func TestNewProxy_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
t.Parallel()
cache, err := imgcache.NewCache(setupTestDB(t),
imgcache.Config{StateDir: t.TempDir(), MaxBytes: 1 << 30},
prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
registry := prometheus.NewRegistry()
_, err = NewProxy(&Config{Cache: cache}, registry)
if err != nil {
t.Fatalf("NewProxy() of the first proxy error = %v", err)
}
_, err = NewProxy(&Config{Cache: cache}, registry)
t.Logf("NewProxy() of the second proxy error = %v", err)
var alreadyRegistered prometheus.AlreadyRegisteredError
if !errors.As(err, &alreadyRegistered) {
t.Errorf("NewProxy() of the second proxy error = %v, want %T",
err, alreadyRegistered)
}
}
@@ -1,584 +0,0 @@
package imageproxy
import (
"context"
"io"
"os"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
)
// Test data literals used repeatedly in this file (goconst).
const (
testPathPhoto = "/images/photo.jpg"
testPathUpload = "/uploads/image.jpg"
)
func TestProxy_Get_GoodHost(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer func() { _ = resp.Content.Close() }()
// Verify we got content
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty response")
}
if resp.ContentType != testContentTypeJPEG {
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
}
}
// TestProxy_Get_OtherHost checks that the proxy serves a second host as it
// serves GoodHost.
func TestProxy_Get_OtherHost(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.OtherHost,
Path: testPathUpload,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
// Should fetch successfully
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer func() { _ = resp.Content.Close() }()
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty response")
}
}
func TestProxy_Get_InvalidFile(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := photoVariant(fixtures, 85, imagerequest.FitCover)
req.Source.Path = "/images/fake.jpg"
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
// Should fail because magic bytes don't match
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for invalid file")
}
}
func TestProxy_Get_NotFound(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := photoVariant(fixtures, 85, imagerequest.FitCover)
req.Source.Path = "/images/nonexistent.jpg"
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for nonexistent file")
}
}
func TestProxy_Get_FormatConversion(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
tests := []struct {
name string
sourcePath string
outFormat imagerequest.Format
wantMIME string
}{
{
name: "JPEG to PNG",
sourcePath: testPathPhoto,
outFormat: imagerequest.FormatPNG,
wantMIME: "image/png",
},
{
name: "PNG to JPEG",
sourcePath: "/images/logo.png",
outFormat: imagerequest.FormatJPEG,
wantMIME: testContentTypeJPEG,
},
{
name: "GIF to PNG",
sourcePath: "/images/animation.gif",
outFormat: imagerequest.FormatPNG,
wantMIME: "image/png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := &imagerequest.Request{
Source: imagerequest.Source{Host: fixtures.GoodHost, Path: tt.sourcePath},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: tt.outFormat, Quality: 85, FitMode: imagerequest.FitCover,
},
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer func() { _ = resp.Content.Close() }()
if resp.ContentType != tt.wantMIME {
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
}
// Verify magic bytes match the expected format
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
detectedMIME, err := magic.DetectFormat(data)
if err != nil {
t.Fatalf("failed to detect format: %v", err)
}
expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
if !ok {
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
}
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
if !ok {
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
}
if detectedFormat != expectedFormat {
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
}
})
}
}
func TestProxy_Get_Caching(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
// First request - should be a cache miss
resp1, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
if resp1.CacheStatus != CacheMiss {
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
}
data1, err := io.ReadAll(resp1.Content)
if err != nil {
t.Fatalf("failed to read first response: %v", err)
}
_ = resp1.Content.Close()
// Second request - should be a cache hit
resp2, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() second request error = %v", err)
}
if resp2.CacheStatus != CacheHit {
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
}
data2, err := io.ReadAll(resp2.Content)
if err != nil {
t.Fatalf("failed to read second response: %v", err)
}
_ = resp2.Content.Close()
// Content should be identical
if len(data1) != len(data2) {
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
}
}
// TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
// a variant of it and sets back two days every time pixa stored for them.
// For the source: last_accessed_at in source_content, and the access and
// modification times of its file. For the variant: last_accessed_at in
// variant_content, and the access and modification times of its file. It
// then checks that the variant is still served from the cache and that a new
// variant is made from the cached source without fetching it again.
//
// Left at the present: the files' status change and birth times, which a
// program cannot set, and the times of the directories holding the files,
// which other entries share.
func TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
resp, err := svc.Get(t.Context(), variant)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
_ = resp.Content.Close()
twoDaysAgo := time.Now().AddDate(0, 0, -2)
backdateSource(t, svc, variant, twoDaysAgo)
backdateVariant(t, svc, variant, twoDaysAgo)
resp, err = svc.Get(t.Context(), variant)
if err != nil {
t.Fatalf("Get() of the cached variant error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Errorf("cached variant CacheStatus = %q, want %q",
resp.CacheStatus, CacheHit)
}
// Another quality is a new variant, made from the cached source
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
if err != nil {
t.Fatalf("Get() of a new variant error = %v", err)
}
_ = resp.Content.Close()
fetches := readCounters(t, svc).upstreamFetchCount
if fetches != 1 {
t.Errorf("upstream fetches = %d, want 1", fetches)
}
}
// backdateSource sets every time pixa stored for req's cached source to when,
// as listed on TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo.
func backdateSource(
t *testing.T, svc *testProxy, req *imagerequest.Request, when time.Time,
) {
t.Helper()
key := req.Source.Key()
setSourceLastAccessed(t, svc.db, key, when)
path := sourceFilePath(svc, key)
err := os.Chtimes(path, when, when)
if err != nil {
t.Fatalf("setting back the times of %s: %v", path, err)
}
}
// backdateVariant sets every time pixa stored for the cached variant req asks
// for to when, as listed on
// TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo.
func backdateVariant(
t *testing.T, svc *testProxy, req *imagerequest.Request, when time.Time,
) {
t.Helper()
key := imagerequest.CacheKey(req)
path := variantFilePath(svc, key)
setVariantLastAccessed(t, svc.db, key, when)
err := os.Chtimes(path, when, when)
if err != nil {
t.Fatalf("setting back the times of %s: %v", path, err)
}
}
func TestProxy_Get_DifferentSizes(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
// Request same image at different sizes
sizes := []imagerequest.Size{
{Width: 25, Height: 25},
{Width: 50, Height: 50},
{Width: 75, Height: 75},
}
responses := make([][]byte, 0, len(sizes))
for _, size := range sizes {
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: size,
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error for size %v = %v", size, err)
}
data, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
_ = resp.Content.Close()
responses = append(responses, data)
}
// All responses should be different sizes (different cache entries)
for i := range len(responses) - 1 {
if len(responses[i]) == len(responses[i+1]) {
// Not necessarily an error, but worth noting
t.Logf("responses %d and %d have same size: %d bytes",
i, i+1, len(responses[i]))
}
}
}
func TestProxy_Get_ContextCancellation(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
_, err := svc.Get(ctx, req)
if err == nil {
t.Error("Get() expected error for cancelled context")
}
}
func TestProxy_Get_ReturnsETag(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
resp, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer func() { _ = resp.Content.Close() }()
// ETag should be set
if resp.ETag == "" {
t.Error("ETag should be set in response")
}
// ETag should be properly quoted
if len(resp.ETag) < 2 || resp.ETag[0] != '"' || resp.ETag[len(resp.ETag)-1] != '"' {
t.Errorf("ETag should be quoted, got %q", resp.ETag)
}
}
func TestProxy_Get_ETagConsistency(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
// First request
resp1, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
etag1 := resp1.ETag
_ = resp1.Content.Close()
// Second request (from cache)
resp2, err := svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() second request error = %v", err)
}
etag2 := resp2.ETag
_ = resp2.Content.Close()
// ETags should be identical for the same content
if etag1 != etag2 {
t.Errorf("ETags should match: %q != %q", etag1, etag2)
}
}
func TestProxy_Get_DifferentETagsForDifferentContent(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx := context.Background()
// Request same image at different sizes - should get different ETags
req1 := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 25, Height: 25},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
req2 := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
resp1, err := svc.Get(ctx, req1)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
etag1 := resp1.ETag
_ = resp1.Content.Close()
resp2, err := svc.Get(ctx, req2)
if err != nil {
t.Fatalf("Get() second request error = %v", err)
}
etag2 := resp2.ETag
_ = resp2.Content.Close()
// ETags should be different for different content
if etag1 == etag2 {
t.Error("ETags should differ for different sizes")
}
}
@@ -1,96 +0,0 @@
package imageproxy
import (
"errors"
"os"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestProxy_Get_ServesCachedVariantOfNegativeCachedURL caches a variant of
// the test photo, then keeps a failed fetch of the photo's URL in the negative
// cache, as a failed fetch for another variant does once the source has been
// evicted. The cached variant is still served, and a variant not in the cache
// is made from the cached source. Once the source is evicted, a variant not in
// the cache needs a fetch and is refused.
func TestProxy_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
_ = resp.Content.Close()
err = svc.cache.PutFailure(t.Context(),
photoVariant(fixtures, 85, imagerequest.FitCover).Source)
if err != nil {
t.Fatalf("PutFailure() error = %v", err)
}
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
if err != nil {
t.Fatalf("Get() of the cached variant error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Errorf("cached variant CacheStatus = %q, want %q",
resp.CacheStatus, CacheHit)
}
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
t.Logf("Get() of a variant made from the cached source: error = %v", err)
if err != nil {
t.Fatalf("Get() of a variant made from the cached source error = %v", err)
}
_ = resp.Content.Close()
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
reader, _, err := svc.cache.Source(t.Context(), source)
if err != nil {
t.Fatalf("Source() error = %v, want the cached source", err)
}
_ = reader.Close()
// Evicts the source as the cache's eviction does: its row, then its file
_, err = svc.db.ExecContext(t.Context(),
`DELETE FROM source_content WHERE source_key = ?`, string(source.Key()))
if err != nil {
t.Fatalf("deleting the source's row: %v", err)
}
err = os.Remove(sourceFilePath(svc, source.Key()))
if err != nil {
t.Fatalf("removing the source's file: %v", err)
}
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, imagerequest.FitCover))
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
if !errors.Is(err, ErrNegativeCached) {
t.Errorf("Get() of a variant whose source was evicted error = %v, want %v",
err, ErrNegativeCached)
}
}
func TestProxy_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
t.Parallel()
// This test verifies that Proxy.Get() checks the negative cache
// We can't easily test the full pipeline without vips, but we can
// verify the error type
err := ErrNegativeCached
if !errors.Is(err, ErrNegativeCached) {
t.Error("ErrNegativeCached should be identifiable with errors.Is")
}
}
@@ -1,273 +0,0 @@
package imageproxy
import (
"bytes"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// getImage calls Get for req and returns the image it answered with and
// its cache status.
func getImage(
t *testing.T, svc *testProxy, req *imagerequest.Request,
) ([]byte, CacheStatus) {
t.Helper()
resp, err := svc.Get(t.Context(), req)
if err != nil {
t.Fatalf("Get() of quality %d error = %v", req.Transform.Quality, err)
}
defer func() { _ = resp.Content.Close() }()
body, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("reading the image of quality %d: %v", req.Transform.Quality, err)
}
return body, resp.CacheStatus
}
// fileSize returns the size of the file at path.
func fileSize(t *testing.T, path string) int64 {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
return info.Size()
}
// truncateFile cuts the file at path down to size bytes.
func truncateFile(t *testing.T, path string, size int64) {
t.Helper()
t.Logf("cutting %s from %d to %d bytes",
filepath.Base(path), fileSize(t, path), size)
err := os.Truncate(path, size)
if err != nil {
t.Fatalf("truncating %s: %v", path, err)
}
}
// cachedSourcePath returns the path of the cached source file req uses.
func cachedSourcePath(
t *testing.T, svc *testProxy, req *imagerequest.Request,
) string {
t.Helper()
reader, _, err := svc.cache.Source(t.Context(), req.Source)
if err != nil {
t.Fatalf("Source() error = %v; want the cached source", err)
}
_ = reader.Close()
return sourceFilePath(svc, req.Source.Key())
}
// reconcileAsAtStartup runs the reconciliation pass of a second cache over
// the database and the state directory of svc's cache, as pixad does when it
// starts. It waits for the pass to remove the file at path, stops the second
// cache, and returns what that cache logged.
//
// The database is limited to one connection, the one that holds the
// in-memory schema. A statement cancelled while it runs can close that
// connection, and the database with it. The test therefore takes the
// connection before it stops the second cache, so that the stop only cancels
// a wait for the connection.
func reconcileAsAtStartup(t *testing.T, svc *testProxy, path string) string {
t.Helper()
var logBuf bytes.Buffer
cache, err := imgcache.NewCache(svc.db, imgcache.Config{
StateDir: svc.stateDir,
MaxBytes: 1 << 30,
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create the second cache: %v", err)
}
svc.db.SetMaxOpenConns(1)
cache.StartEviction(imgcache.DefaultEvictionInterval)
deadline := time.Now().Add(5 * time.Second)
_, statErr := os.Stat(path)
for statErr == nil && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
_, statErr = os.Stat(path)
}
releaseDatabase := holdDatabase(t, svc.db)
err = cache.StopEviction(t.Context())
releaseDatabase()
if err != nil {
t.Fatalf("StopEviction() error = %v", err)
}
t.Logf("reconciliation log: %s", logBuf.String())
if !errors.Is(statErr, fs.ErrNotExist) {
t.Fatalf("the reconciliation pass did not remove %s: %v", path, statErr)
}
return logBuf.String()
}
// assertWarningNames checks that logs hold a warning naming the file at
// path.
func assertWarningNames(t *testing.T, logs, path string) {
t.Helper()
for line := range strings.Lines(logs) {
if strings.Contains(line, `"level":"WARN"`) &&
strings.Contains(line, `"path":"`+path+`"`) {
return
}
}
t.Errorf("reconciliation logged no warning naming %s", path)
}
// TestProxy_Get_FetchesAgainForEmptyCachedSource empties the cached source
// file, as a crash or a restored snapshot could, and runs the reconciliation
// pass, which must remove it with a warning naming it. It then asks for two
// new sizes of the source: the first must fetch it again and cache it whole,
// so that the second is made from the cache without a third fetch.
func TestProxy_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
wholeSize := fileSize(t, path)
truncateFile(t, path, 0)
assertWarningNames(t, reconcileAsAtStartup(t, svc, path), path)
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
fetches := readCounters(t, svc).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
t.Errorf("upstream fetches = %d, want 2: the empty cached source "+
"was kept and fetched again on every new size", fetches)
}
size := fileSize(t, path)
if size != wholeSize {
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
}
}
// TestProxy_Get_FetchesAgainForPartialCachedSource cuts the cached source
// file to half its size, as a crash or a restored snapshot could, and runs
// the reconciliation pass, which must remove it with a warning naming it.
// It then asks for two new sizes of the source: the first must fetch it
// again rather than fail to decode the partial file, and the second is
// made from the cache.
func TestProxy_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
wholeSize := fileSize(t, path)
truncateFile(t, path, wholeSize/2)
assertWarningNames(t, reconcileAsAtStartup(t, svc, path), path)
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
fetches := readCounters(t, svc).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
t.Errorf("upstream fetches = %d, want 2", fetches)
}
size := fileSize(t, path)
if size != wholeSize {
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
}
}
// assertConvertedAgain caches a variant of the photo, cuts its file to size
// bytes and runs the reconciliation pass, which must remove it with a
// warning naming it. It then checks that Get converts the image again
// instead of serving the file, and that the next Get serves the whole
// variant from the cache.
func assertConvertedAgain(t *testing.T, size int64) {
t.Helper()
svc, fixtures := setupTestProxy(t)
req := photoVariant(fixtures, 85, imagerequest.FitCover)
want, _ := getImage(t, svc, req)
path := variantFilePath(svc, imagerequest.CacheKey(req))
truncateFile(t, path, size)
assertWarningNames(t, reconcileAsAtStartup(t, svc, path), path)
got, status := getImage(t, svc, req)
t.Logf("after the cut: %d bytes, %s", len(got), status)
if status != CacheMiss || !bytes.Equal(got, want) {
t.Errorf("after the cut: Get() = %d bytes, %s; want the %d bytes "+
"converted again, %s", len(got), status, len(want), CacheMiss)
}
got, status = getImage(t, svc, req)
t.Logf("next request: %d bytes, %s", len(got), status)
if status != CacheHit || !bytes.Equal(got, want) {
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
len(got), status, len(want), CacheHit)
}
}
// TestProxy_Get_ConvertsAgainForEmptyCachedVariant empties a cached
// variant file, as a crash or a restored snapshot could, and checks that it
// is converted again rather than served empty.
func TestProxy_Get_ConvertsAgainForEmptyCachedVariant(t *testing.T) {
t.Parallel()
assertConvertedAgain(t, 0)
}
// TestProxy_Get_ConvertsAgainForPartialCachedVariant cuts a cached
// variant file to ten bytes, as a crash or a restored snapshot could, and
// checks that it is converted again rather than served cut short.
func TestProxy_Get_ConvertsAgainForPartialCachedVariant(t *testing.T) {
t.Parallel()
assertConvertedAgain(t, 10)
}
@@ -1,78 +0,0 @@
package imageproxy
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// pendingAccessTimesWait is how long a test waits for the database's
// connection, for a cache hit to be answered, for an access time to reach
// the database, or for a request to return past its deadline.
const pendingAccessTimesWait = 5 * time.Second
// holdDatabase takes the one connection of db, so that every other query
// waits for it, and returns the func that frees it.
func holdDatabase(t *testing.T, db *sql.DB) func() {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), pendingAccessTimesWait)
defer cancel()
conn, err := db.Conn(ctx)
if err != nil {
t.Fatalf("failed to take the database connection: %v", err)
}
return func() { _ = conn.Close() }
}
// TestProxy_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
// database's one connection while a request whose fetch is held reaches its
// deadline. The request must return with the deadline's error while the
// connection is still held, as it waits for no database write past its
// deadline.
func TestProxy_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc.cache.StartPendingAccessTimeWrites()
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
// Room for the request to reach its fetch on a busy host
const timeout = 2 * time.Second
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
deadline, _ := ctx.Deadline()
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
// The request has made its database reads by the time it fetches.
select {
case <-fetcher.started:
case <-time.After(timeout):
t.Fatal("request did not reach its fetch by its deadline")
}
releaseDatabase := holdDatabase(t, svc.db)
defer releaseDatabase()
select {
case got := <-results:
t.Logf("Get() returned %v after its deadline, error = %v",
time.Since(deadline), got.err)
if !errors.Is(got.err, context.DeadlineExceeded) {
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
}
case <-time.After(time.Until(deadline) + pendingAccessTimesWait):
t.Fatal("request did not return while the database was busy")
}
}
-245
View File
@@ -1,245 +0,0 @@
package imageproxy
import (
"bytes"
"context"
"image/color"
"io"
"io/fs"
"testing"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestProxy_Get_CountsStats walks Get through a miss that fetches the
// source, a hit, a miss that reuses the cached source, and two misses whose
// source cannot be used, checking every counter of the proxy and its cache
// after each.
func TestProxy_Get_CountsStats(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
// NewTestFS builds the same files the test proxy's fetcher serves.
testFS, _ := NewTestFS(t)
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
if err != nil {
t.Fatal(err)
}
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
if err != nil {
t.Fatal(err)
}
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
// want is hits, misses, upstream fetches, upstream bytes, transforms.
steps := []struct {
name string
path string
size int
wantErr bool
want counterValues
}{
{"miss that fetches the source", testPathPhoto, 50, false,
counterValues{0, 1, 1, photoBytes, 1}},
{"hit", testPathPhoto, 50, false,
counterValues{1, 1, 1, photoBytes, 1}},
{"miss that reuses the cached source", testPathPhoto, 25, false,
counterValues{1, 2, 1, photoBytes, 2}},
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
counterValues{1, 3, 2, photoBytes + fakeBytes, 2}},
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
counterValues{1, 4, 2, photoBytes + fakeBytes, 2}},
}
for _, step := range steps {
resp, err := svc.Get(t.Context(), &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: step.path,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: step.size, Height: step.size},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
})
if (err != nil) != step.wantErr {
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
}
if err == nil {
_ = resp.Content.Close()
}
got := readCounters(t, svc)
if got != step.want {
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
}
}
}
// fakeUpstream answers every fetch with itself as a JPEG body. The body
// serves data, then calls cancel, when set, and returns err; io.EOF ends
// the body normally.
type fakeUpstream struct {
data *bytes.Reader
cancel context.CancelFunc
err error
}
func (u *fakeUpstream) Fetch(
context.Context, imagerequest.Source,
) (*httpfetcher.FetchResult, error) {
return &httpfetcher.FetchResult{
Content: io.NopCloser(u),
ContentLength: -1,
ContentType: testContentTypeJPEG,
}, nil
}
func (u *fakeUpstream) Read(p []byte) (int, error) {
if u.data.Len() > 0 {
return u.data.Read(p)
}
if u.cancel != nil {
u.cancel()
}
return 0, u.err
}
// TestProxy_Get_CountsInterruptedMisses checks every counter of the proxy
// and its cache after a miss whose request context ends during or after the
// upstream fetch, and after a miss whose upstream body is over the size
// limit.
func TestProxy_Get_CountsInterruptedMisses(t *testing.T) {
t.Parallel()
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
half := len(photo) / 2
// want is hits, misses, upstream fetches, upstream bytes, transforms.
tests := []struct {
name string
served int // bytes of the photo the upstream body serves
cancel bool // whether the body then ends the request context
readErr error // what the body then returns
wantErr bool
want counterValues
}{
{"request context ends during the fetch", half, true, context.Canceled, true,
counterValues{0, 1, 1, int64(half), 0}},
{"request context ends after the fetch", len(photo), true, io.EOF, false,
counterValues{0, 1, 1, int64(len(photo)), 1}},
{"upstream body over the size limit", half, false,
httpfetcher.ErrResponseTooLarge, true,
counterValues{0, 1, 1, int64(half), 0}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
upstream := &fakeUpstream{
data: bytes.NewReader(photo[:tc.served]),
err: tc.readErr,
}
if tc.cancel {
upstream.cancel = cancel
}
svc.fetcher = upstream
resp, err := svc.Get(ctx, &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
})
t.Logf("Get() error = %v", err)
if (err != nil) != tc.wantErr {
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
}
if err == nil {
_ = resp.Content.Close()
}
got := readCounters(t, svc)
if got != tc.want {
t.Errorf("counters = %+v, want %+v", got, tc.want)
}
})
}
}
// TestProxy_Get_CountsHitAfterRequestEnds checks every counter of the
// proxy and its cache after a hit served with a request context that has
// already ended: only the hit count moves.
func TestProxy_Get_CountsHitAfterRequestEnds(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}
// A first request caches the variant.
resp, err := svc.Get(t.Context(), req)
if err != nil {
t.Fatalf("first Get() error = %v", err)
}
_ = resp.Content.Close()
want := readCounters(t, svc)
want.hitCount++
ctx, cancel := context.WithCancel(t.Context())
cancel()
resp, err = svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() with an ended request context: error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
}
got := readCounters(t, svc)
if got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
}
@@ -1,355 +0,0 @@
package imageproxy
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io/fs"
"path/filepath"
"testing"
"testing/fstest"
"time"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Shared test data literals, extracted as constants for goconst.
const (
testContentTypeJPEG = "image/jpeg"
)
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
// produces, so injected timestamps compare correctly against ones the
// cache writes.
const sqliteTimestampFormat = "2006-01-02 15:04:05"
// TestFixtures contains paths to test files in the mock filesystem.
type TestFixtures struct {
// Valid image files
GoodHostJPEG string // valid JPEG on GoodHost
GoodHostPNG string // valid PNG on GoodHost
GoodHostGIF string // valid GIF on GoodHost
OtherHostJPEG string // valid JPEG on OtherHost
OtherHostPNG string // valid PNG on OtherHost
// Invalid/edge case files
InvalidFile string // file with wrong magic bytes
EmptyFile string // zero-byte file
TextFile string // text file masquerading as image
// Hostnames
GoodHost string // a host the mock fetcher serves
OtherHost string // a second host the mock fetcher serves
}
// DefaultFixtures returns the standard test fixture paths.
func DefaultFixtures() *TestFixtures {
return &TestFixtures{
GoodHostJPEG: "goodhost.example.com/images/photo.jpg",
GoodHostPNG: "goodhost.example.com/images/logo.png",
GoodHostGIF: "goodhost.example.com/images/animation.gif",
OtherHostJPEG: "otherhost.example.com/uploads/image.jpg",
OtherHostPNG: "otherhost.example.com/uploads/icon.png",
InvalidFile: "goodhost.example.com/images/fake.jpg",
EmptyFile: "goodhost.example.com/images/empty.jpg",
TextFile: "goodhost.example.com/images/text.png",
GoodHost: "goodhost.example.com",
OtherHost: "otherhost.example.com",
}
}
// NewTestFS creates a mock filesystem with test images.
func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) {
t.Helper()
fixtures := DefaultFixtures()
// Generate test images
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255})
gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255})
// Create the mock filesystem
mockFS := fstest.MapFS{
// Good host files
fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData},
fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData},
fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData},
// Other host files
fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData},
fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData},
// Invalid files
fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")},
fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}},
fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")},
}
return mockFS, fixtures
}
// generateTestJPEG creates a minimal valid JPEG image.
func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
if err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
return buf.Bytes()
}
// generateTestPNG creates a minimal valid PNG image.
func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
// generateTestGIF creates a minimal valid GIF image.
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewPaletted(
image.Rect(0, 0, width, height),
[]color.Color{c, color.White},
)
for y := range height {
for x := range width {
img.SetColorIndex(x, y, 0)
}
}
var buf bytes.Buffer
err := gif.Encode(&buf, img, nil)
if err != nil {
t.Fatalf("failed to encode test GIF: %v", err)
}
return buf.Bytes()
}
// testProxy is a Proxy with the database and the state directory of its
// cache, through which a test reads and changes the cache behind the proxy.
// registry is the metrics registry the proxy and its cache share.
type testProxy struct {
*Proxy
db *sql.DB
stateDir string
registry *prometheus.Registry
}
// setupTestProxy creates a Proxy with mock fetcher for testing.
func setupTestProxy(t *testing.T) (*testProxy, *TestFixtures) {
t.Helper()
mockFS, fixtures := NewTestFS(t)
// Create temp directory for cache
tmpDir := t.TempDir()
// Create in-memory database
db := setupTestDB(t)
registry := prometheus.NewRegistry()
cache, err := imgcache.NewCache(db, imgcache.Config{
StateDir: tmpDir,
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, registry)
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
proxy, err := NewProxy(&Config{
Cache: cache,
Fetcher: httpfetcher.NewMock(mockFS),
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
MaxResponseSize: 50 << 20,
}, registry)
if err != nil {
t.Fatalf("failed to create proxy: %v", err)
}
return &testProxy{
Proxy: proxy,
db: db,
stateDir: tmpDir,
registry: registry,
}, fixtures
}
// counterValues holds the values of the counters of a proxy and its cache.
type counterValues struct {
hitCount int64
missCount int64
upstreamFetchCount int64
upstreamFetchBytes int64
transformCount int64
}
// readCounters reads the counters of svc and its cache from their metrics
// registry.
func readCounters(t *testing.T, svc *testProxy) counterValues {
t.Helper()
families, err := svc.registry.Gather()
if err != nil {
t.Fatalf("failed to gather the counters: %v", err)
}
values := make(map[string]int64, len(families))
for _, family := range families {
values[family.GetName()] = int64(family.GetMetric()[0].GetCounter().GetValue())
}
return counterValues{
hitCount: values["pixa_cache_hits_total"],
missCount: values["pixa_cache_misses_total"],
upstreamFetchCount: values["pixa_upstream_fetches_total"],
upstreamFetchBytes: values["pixa_upstream_fetch_bytes_total"],
transformCount: values["pixa_transforms_total"],
}
}
// sourceFilePath returns the path of the file of the source key in svc's
// cache, as README.md gives it: <state_dir>/cache/sources/<ab>/<cd>/<key>.
func sourceFilePath(svc *testProxy, key imagerequest.SourceKey) string {
name := string(key)
return filepath.Join(svc.stateDir, "cache", "sources", name[0:2], name[2:4], name)
}
// variantFilePath returns the path of the file of the variant key in svc's
// cache, as README.md gives it: <state_dir>/cache/variants/<ab>/<cd>/<key>.
func variantFilePath(svc *testProxy, key imagerequest.VariantKey) string {
name := string(key)
return filepath.Join(svc.stateDir, "cache", "variants", name[0:2], name[2:4], name)
}
// setVariantLastAccessed backdates the last access time of a tracked
// variant.
func setVariantLastAccessed(
t *testing.T, db *sql.DB, key imagerequest.VariantKey, when time.Time,
) {
t.Helper()
res, err := db.ExecContext(t.Context(),
`UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`,
when.UTC().Format(sqliteTimestampFormat), string(key),
)
if err != nil {
t.Fatalf("failed to set variant last_accessed_at: %v", err)
}
affected, err := res.RowsAffected()
if err != nil {
t.Fatalf("failed to read affected rows: %v", err)
}
if affected != 1 {
t.Fatalf("variant %s has no accounting row (affected=%d); "+
"stores must track variants in the database", key, affected)
}
}
// setSourceLastAccessed backdates the last access time of a tracked
// source content blob.
func setSourceLastAccessed(
t *testing.T, db *sql.DB, key imagerequest.SourceKey, when time.Time,
) {
t.Helper()
res, err := db.ExecContext(t.Context(),
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
when.UTC().Format(sqliteTimestampFormat), string(key),
)
if err != nil {
t.Fatalf("failed to set source last_accessed_at: %v", err)
}
affected, err := res.RowsAffected()
if err != nil {
t.Fatalf("failed to read affected rows: %v", err)
}
if affected != 1 {
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
}
}
// countRows returns the number of rows the given query yields.
func countRows(t *testing.T, db *sql.DB, query string, args ...any) int {
t.Helper()
var n int
err := db.QueryRowContext(t.Context(), query, args...).Scan(&n)
if err != nil {
t.Fatalf("count query %q failed: %v", query, err)
}
return n
}
// setupTestDB creates an in-memory SQLite database for testing
// using the production schema.
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
// Use the real production schema via migrations
err = database.ApplyMigrations(context.Background(), db, nil)
if err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
return db
}
-137
View File
@@ -1,137 +0,0 @@
// Package imagerequest holds the image request type, its parts, its checks,
// the parsing of the /v1/image/ path, the variant key, the source key and the
// source's upstream URL. It imports no other package of this repo, so every
// package that handles an image request can import it.
package imagerequest
import (
"errors"
"net/url"
)
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// Format represents supported output image formats.
type Format string
// Supported image output formats.
const (
FormatOriginal Format = "orig"
FormatJPEG Format = "jpeg"
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif"
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
// request from its Accept header once the URL's signature or token has
// been checked; it is never processed or cached as itself.
FormatAuto Format = "auto"
)
// Size represents requested image dimensions
type Size struct {
Width int
Height int
}
// OriginalSize returns true if this represents "keep original size"
func (s Size) OriginalSize() bool {
return s.Width == 0 && s.Height == 0
}
// Fit represents how to fit image into requested dimensions.
type Fit string
// Supported image fit modes.
const (
FitCover Fit = "cover"
FitContain Fit = "contain"
FitFill Fit = "fill"
FitInside Fit = "inside"
FitOutside Fit = "outside"
)
// ValidateFitMode checks if the given fit mode is valid.
// Returns ErrInvalidFitMode for unrecognized fit modes.
func ValidateFitMode(fit Fit) error {
switch fit {
case FitCover, FitContain, FitFill, FitInside, FitOutside, "":
return nil
default:
return ErrInvalidFitMode
}
}
// ValidateImageRequest checks a request's width and height with
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
// /v1/image/ route and the encrypted /v1/e/ route validate through this
// function so a request from either source enforces identical bounds.
func ValidateImageRequest(req *Request) error {
err := ValidateDimension("width", req.Transform.Size.Width)
if err != nil {
return err
}
err = ValidateDimension("height", req.Transform.Size.Height)
if err != nil {
return err
}
return ValidateFitMode(req.Transform.FitMode)
}
// Request represents a request for a processed image: the source to fetch
// and the transform to apply to it. It holds exactly what the variant key
// covers.
type Request struct {
// Source is what to fetch
Source Source
// Transform is what to do to it
Transform Transform
}
// Source is the image a request fetches from the origin.
type Source struct {
// Host is the origin host (e.g., "cdn.example.com")
Host string
// Path is the path on the origin with its percent-escapes decoded, as
// url.URL's Path holds it (e.g., "/photos/my cat.jpg")
Path string
// Query is the optional query string for the origin URL as the URL
// writes it, percent-escapes included, as url.URL's RawQuery holds it
Query string
}
// Transform is what a request does to its source; the image processor
// takes it alone.
type Transform struct {
// Size is the requested output dimensions
Size Size
// Format is the requested output format
Format Format
// Quality is the output quality (1-100) for lossy formats
Quality int
// FitMode is how to fit the image into requested dimensions
FitMode Fit
}
// URL returns the source's upstream URL, with the scheme http when allowHTTP
// is true and https otherwise. net/url writes its path percent-encoded again,
// so each source path is one upstream path. Its query is the source query as
// it is, with the characters escapeQuery encodes percent-encoded.
func (s Source) URL(allowHTTP bool) *url.URL {
scheme := "https"
if allowHTTP {
scheme = "http"
}
return &url.URL{
Scheme: scheme,
Host: s.Host,
Path: s.Path,
RawQuery: escapeQuery(s.Query),
}
}
-26
View File
@@ -1,26 +0,0 @@
package imagerequest
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// SourceKey is a SHA256 hash identifying a cached source image (hex-encoded).
type SourceKey string
// Key returns the key the source is cached under, made as CacheKey makes a
// variant key.
// Format: sha256(host:path:query)
// The path and query go through escapePercentAndColon, so that a ":" in
// either cannot be taken for the one between them.
func (s Source) Key() SourceKey {
data := fmt.Sprintf("%s:%s:%s",
s.Host,
escapePercentAndColon(s.Path),
escapePercentAndColon(s.Query),
)
h := sha256.Sum256([]byte(data))
return SourceKey(hex.EncodeToString(h[:]))
}
@@ -1,40 +0,0 @@
package imagerequest
import "testing"
// TestSourceKey_DiffersForEachSource checks that each source gets a key of
// 64 hex characters, the same each time, and that no two of these sources
// share one, also when they differ only by a ":" or "%" in the path or query.
func TestSourceKey_DiffersForEachSource(t *testing.T) {
t.Parallel()
sources := []Source{
{Host: testHostCDN, Path: testPathCat},
{Host: testHostCDN, Path: testPathCat, Query: "v=2"},
{Host: "other.example.com", Path: testPathCat},
{Host: testHostCDN, Path: "/c:d"},
{Host: testHostCDN, Path: "/c", Query: "d:"},
{Host: testHostCDN, Path: "/c%3Ad"},
}
seen := make(map[SourceKey]Source)
for _, source := range sources {
key := source.Key()
t.Logf("Key() of %+v = %s", source, key)
if len(key) != 64 {
t.Errorf("Key() of %+v length = %d, want 64", source, len(key))
}
if again := source.Key(); again != key {
t.Errorf("Key() of %+v not deterministic: %s vs %s", source, key, again)
}
if earlier, found := seen[key]; found {
t.Errorf("Key() gave %+v and %+v the same key", earlier, source)
}
seen[key] = source
}
}
@@ -1,50 +0,0 @@
package imagerequest
import "testing"
func TestSource_URL_DefaultHTTPS(t *testing.T) {
t.Parallel()
source := Source{
Host: testHostCDN,
Path: testPathCat,
Query: "v=2",
}
got := source.URL(false).String()
want := "https://cdn.example.com/photos/cat.jpg?v=2"
if got != want {
t.Errorf("URL() = %q, want %q", got, want)
}
}
func TestSource_URL_AllowHTTP(t *testing.T) {
t.Parallel()
source := Source{
Host: "localhost:8080",
Path: testPathCat,
}
got := source.URL(true).String()
want := "http://localhost:8080/photos/cat.jpg"
if got != want {
t.Errorf("URL() = %q, want %q", got, want)
}
}
func TestSource_URL_AllowHTTPFalse(t *testing.T) {
t.Parallel()
source := Source{
Host: testHostCDN,
Path: "/img.jpg",
}
got := source.URL(false).String()
if got != "https://cdn.example.com/img.jpg" {
t.Errorf("URL() = %q, want https scheme", got)
}
}
-39
View File
@@ -1,39 +0,0 @@
package imagerequest
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
type VariantKey string
// CacheKey generates a unique key for a request variant.
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
// The path and query go through escapePercentAndColon, so that a ":" in
// either cannot be taken for the one between them.
func CacheKey(req *Request) VariantKey {
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
req.Source.Host,
escapePercentAndColon(req.Source.Path),
escapePercentAndColon(req.Source.Query),
req.Transform.Size.Width,
req.Transform.Size.Height,
req.Transform.Format,
req.Transform.Quality,
req.Transform.FitMode,
)
h := sha256.Sum256([]byte(data))
return VariantKey(hex.EncodeToString(h[:]))
}
// escapePercentAndColon returns s with each "%" written as "%25" and each
// ":" as "%3A", so that the result holds no ":".
func escapePercentAndColon(s string) string {
s = strings.ReplaceAll(s, "%", "%25")
return strings.ReplaceAll(s, ":", "%3A")
}
@@ -1,142 +0,0 @@
package imagerequest
import "testing"
func TestCacheKey(t *testing.T) {
t.Parallel()
req1 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Quality: 85, FitMode: FitCover,
},
}
req2 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Quality: 85, FitMode: FitCover,
},
}
// Same request should produce same key
key1 := CacheKey(req1)
key2 := CacheKey(req2)
if key1 != key2 {
t.Errorf("CacheKey() not deterministic: %s vs %s", key1, key2)
}
// Key should be 64 hex chars
if len(string(key1)) != 64 {
t.Errorf("CacheKey() length = %d, want 64", len(string(key1)))
}
// Different size should produce different key
req3 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 400, Height: 300}, Format: FormatWebP, // Different size
Quality: 85, FitMode: FitCover,
},
}
key3 := CacheKey(req3)
if key1 == key3 {
t.Error("CacheKey() produced same key for different sizes")
}
// Different format should produce different key
req4 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatPNG, // Different format
Quality: 85, FitMode: FitCover,
},
}
key4 := CacheKey(req4)
if key1 == key4 {
t.Error("CacheKey() produced same key for different formats")
}
// Different quality should produce different key
req5 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Quality: 50, FitMode: FitCover, // Different quality
},
}
key5 := CacheKey(req5)
if key1 == key5 {
t.Error("CacheKey() produced same key for different quality")
}
}
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
// URLs whose path or query holds ":" or "%" get different keys.
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path1, query1 string
path2, query2 string
}{
{
name: "colon moved from path to query",
path1: "/a:b", query1: "",
path2: "/a", query2: "b:",
},
{
name: "colon in path and its percent-encoding",
path1: "/a:b", query1: "",
path2: "/a%3Ab", query2: "",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
key1 := CacheKey(&Request{
Source: Source{
Host: testHostCDN,
Path: tt.path1,
Query: tt.query1,
},
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
},
})
key2 := CacheKey(&Request{
Source: Source{
Host: testHostCDN,
Path: tt.path2,
Query: tt.query2,
},
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
},
})
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
if key1 == key2 {
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
}
})
}
}
+585 -210
View File
@@ -1,31 +1,36 @@
// Package imgcache is the image cache. It keeps:
// - the source images fetched from upstream
// - the variants made from them
// - the negative cache: the source URLs whose fetch failed recently
//
// Each image is a file with a row in the database. Eviction removes the
// least recently used images to keep the cache under its size limit.
package imgcache
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/imagerequest"
lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/pixa/internal/httpfetcher"
)
// maxPendingAccessTimes is how many access times of variants served from
// the cache, and how many of cached sources a miss used, may each wait to be
// written, at about 80 bytes each. An access time handed over while that
// many of its kind wait is dropped.
const maxPendingAccessTimes = 10000
// Cache errors.
var (
ErrCacheMiss = errors.New("cache miss")
ErrNegativeCache = errors.New("negative cache hit")
)
// HTTP status code for successful fetch.
const httpStatusOK = 200
// metaCacheSize is how many variants' content types metaCache holds. A
// variant not among them is served as before, reading its .meta file.
const metaCacheSize = 10000
// cleanExpiredBatchSize is the most expired negative cache rows one
// statement of CleanExpired deletes. Each batch is a statement of its
@@ -33,22 +38,29 @@ const maxPendingAccessTimes = 10000
// have expired.
const cleanExpiredBatchSize = 1000
// DefaultNegativeTTL is how long pixad keeps a failed fetch in the negative
// cache.
const DefaultNegativeTTL = 5 * time.Minute
// Config holds cache configuration.
type Config struct {
// CacheConfig holds cache configuration.
type CacheConfig struct {
StateDir string
NegativeTTL time.Duration
// MaxBytes is the disk cache size limit in bytes that eviction
// enforces. Zero turns the disk cache off entirely: no cache
// directories are created, lookups always miss, stores are no-ops,
// and the eviction goroutine only removes the expired negative cache
// rows.
// enforces. Zero means no limit is enforced (no eviction).
MaxBytes int64
// UseDefaultMaxBytes makes NewCache replace MaxBytes with the
// default limit: 75% of the sum of the space free on the filesystem
// holding the cache and the bytes the cache already holds, at least
// DefaultCacheMaxBytesFloor. The config layer sets this when the
// operator omits cache_max_bytes.
UseDefaultMaxBytes bool
// DisableDiskCache turns the disk cache off entirely: no cache
// directories are created, lookups always miss, stores are
// no-ops, and the eviction goroutine only removes the expired
// negative cache rows. The config layer sets this when the
// operator configures cache_max_bytes: 0.
DisableDiskCache bool
// Logger receives accounting and eviction log output. A nil
// Logger means slog.Default().
Logger *slog.Logger
@@ -56,11 +68,16 @@ type Config struct {
// Cache implements the caching layer for the image proxy.
type Cache struct {
db *sql.DB
srcContent *ContentStorage // source images by source key
variants *VariantStorage // processed variants by cache key
config Config
log *slog.Logger
db *sql.DB
srcContent *ContentStorage // source images by content hash
variants *VariantStorage // processed variants by cache key
srcMetadata *MetadataStorage // source metadata by host/path
config CacheConfig
log *slog.Logger
// disabled means the disk cache is turned off entirely: lookups
// always miss, stores are no-ops, and no eviction runs.
disabled bool
// Eviction machinery. The channels are created in NewCache so
// stores can signal write pressure without racing StartEviction.
@@ -70,27 +87,47 @@ type Cache struct {
evictionDone chan struct{}
evictionCancel context.CancelFunc
// pendingVariantAccessTimes holds the variants served from the cache,
// and pendingSourceAccessTimes the cached sources a miss used, whose
// access time is still to be written by the goroutine that
// StartPendingAccessTimeWrites starts, so that no request waits for the
// database to record it. As for eviction, the channels are created in
// NewCache, so that requests can hand over access times before that
// goroutine starts; pendingAccessTimesDone is closed when it returns.
// pendingAccessTimesCancel, set by StartPendingAccessTimeWrites,
// cancels its context, which tells it to finish.
pendingVariantAccessTimes chan imagerequest.VariantKey
pendingSourceAccessTimes chan imagerequest.SourceKey
pendingAccessTimesDone chan struct{}
pendingAccessTimesCancel context.CancelFunc
// The pending counts: hits, misses, upstream fetches and transforms
// whose write to the database missed its request's deadline, kept
// here until they are written by the goroutine that
// StartPendingCountWrites starts. As for eviction, the channels are
// created in NewCache: pendingCountsAdded wakes that goroutine, and
// pendingCountsDone is closed when it returns. pendingCountsCancel,
// set by StartPendingCountWrites, cancels its context, which tells it
// to finish.
pendingHits atomic.Int64
pendingMisses atomic.Int64
pendingUpstreamFetches atomic.Int64
pendingUpstreamFetchBytes atomic.Int64
pendingTransforms atomic.Int64
pendingCountsAdded chan struct{}
pendingCountsDone chan struct{}
pendingCountsCancel context.CancelFunc
// The counts of cache hits and misses, made and registered by
// registerCounters (see counters.go)
cacheHits prometheus.Counter
cacheMisses prometheus.Counter
// Held through each write of the pending counts, and by
// readHitAndMissCounts to read them
pendingCountsWriteMutex sync.Mutex
// metaCache holds the content types of the variants most recently
// stored or served, so a hit does not read the variant's .meta file.
// It never stands in for the variant file, which is always opened.
metaCache *lru.Cache[VariantKey, string]
// contentLocks serializes StoreSource and evictSourceBlob per
// content hash, closing the race window between an eviction's row
// deletion and its file unlink against a concurrent store of
// identical content.
contentLocks *contentLock
// evictSourceBlobTestHook, when set, is invoked by evictSourceBlob
// after its row-deletion transaction commits and before the
// content file is unlinked. It exists solely so tests can
// deterministically pause inside that window to exercise
// concurrent stores against it; production code leaves it nil.
evictSourceBlobTestHook func(ContentHash)
// reconciliationPageSize is the most rows one read of a content table
// returns in the reconciliation pass. NewCache sets it to
// returns in the reconciliation pass and in Stats. newCache sets it to
// defaultReconciliationPageSize; tests set it smaller.
reconciliationPageSize int
@@ -101,37 +138,43 @@ type Cache struct {
reconciliationReadTestHook func(rows int)
}
// NewCache creates a new cache instance and registers its counters on
// metricsRegistry. pixad passes Prometheus' default registry, the one
// /metrics serves. It makes no database call.
func NewCache(
db *sql.DB, config Config, metricsRegistry prometheus.Registerer,
// NewCache creates a new cache instance.
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
return newCache(db, config, defaultFreeSpaceProbe)
}
// newCache is NewCache with the free-space probe passed in, so tests
// can fake the free space the default limit is worked out from.
func newCache(
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
) (*Cache, error) {
log := config.Logger
if log == nil {
log = slog.Default()
}
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
if err != nil {
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
}
c := &Cache{
db: db,
config: config,
log: log,
disabled: config.DisableDiskCache,
evictionPressure: make(chan struct{}, 1),
evictionDone: make(chan struct{}),
metaCache: metaCache,
contentLocks: newContentLock(),
pendingVariantAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
pendingSourceAccessTimes: make(chan imagerequest.SourceKey, maxPendingAccessTimes),
pendingAccessTimesDone: make(chan struct{}),
pendingCountsAdded: make(chan struct{}, 1),
pendingCountsDone: make(chan struct{}),
reconciliationPageSize: defaultReconciliationPageSize,
}
err := c.registerCounters(metricsRegistry)
if err != nil {
return nil, err
}
if config.MaxBytes == 0 {
if c.disabled {
return c, nil
}
@@ -143,65 +186,208 @@ func NewCache(
}
variants, err := NewVariantStorage(
filepath.Join(config.StateDir, "cache", "variants"),
filepath.Join(config.StateDir, "cache", "variants"), log,
)
if err != nil {
return nil, fmt.Errorf("failed to create variant storage: %w", err)
}
srcMetadata, err := NewMetadataStorage(
filepath.Join(config.StateDir, "cache", "metadata"),
)
if err != nil {
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
}
c.srcContent = srcContent
c.variants = variants
c.srcMetadata = srcMetadata
if config.UseDefaultMaxBytes {
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
if err != nil {
return nil, err
}
c.config.MaxBytes = limit
log.Info("computed default cache size limit from free space and cache contents",
"cache_max_bytes", limit,
"cache_dir", filepath.Join(config.StateDir, "cache"),
)
}
return c, nil
}
// Variant opens a cached variant and returns a reader, its size and its
// content type, read from the variant file's first bytes. It opens that one
// file and makes no database call. A variant it opens has its access time
// handed to the background writer without waiting (see touchVariant). A
// variant not in the cache, or any variant of a disabled cache, is
// ErrNotFound; a variant file whose first bytes are not those of an output
// format is another error.
func (c *Cache) Variant(
cacheKey imagerequest.VariantKey,
) (io.ReadCloser, int64, string, error) {
if c.config.MaxBytes == 0 {
// LookupResult contains the result of a cache lookup.
type LookupResult struct {
Hit bool
CacheKey VariantKey
ContentType string
SizeBytes int64
CacheStatus CacheStatus
}
// Lookup checks if a processed variant exists on disk. Hits touch the
// variant's LRU timestamp; a disabled cache always misses.
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
cacheKey := CacheKey(req)
// Check variant storage directly - no DB needed for cache hits
if !c.disabled && c.variants.Exists(cacheKey) {
c.touchVariant(ctx, cacheKey)
return &LookupResult{
Hit: true,
CacheKey: cacheKey,
CacheStatus: CacheHit,
}, nil
}
return &LookupResult{
Hit: false,
CacheKey: cacheKey,
CacheStatus: CacheMiss,
}, nil
}
// GetVariant returns a reader, size, and content type for a cached
// variant. The content type comes from metaCache, or else from the
// variant's .meta file and is then kept in metaCache. A variant with
// no .meta file is served as application/octet-stream, which is not
// kept.
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
if c.disabled {
return nil, 0, "", ErrNotFound
}
reader, size, contentType, err := c.variants.LoadWithContentType(cacheKey)
if err != nil {
return nil, 0, "", err
contentType, known := c.metaCache.Get(cacheKey)
if !known {
return c.loadVariantWithMeta(cacheKey)
}
c.touchVariant(cacheKey)
reader, size, err := c.variants.LoadWithSize(cacheKey)
if err != nil {
// The file is gone, e.g. deleted outside pixa
c.metaCache.Remove(cacheKey)
return nil, 0, "", err
}
return reader, size, contentType, nil
}
// PutVariant stores a processed variant by its cache key and records
// StoreSource stores fetched source content and metadata. On a
// disabled cache it is a no-op returning an empty hash.
func (c *Cache) StoreSource(
ctx context.Context,
req *ImageRequest,
content io.Reader,
result *httpfetcher.FetchResult,
) (ContentHash, error) {
if c.disabled {
return "", nil
}
// Hash the content ourselves (rather than via srcContent.Store,
// which would hash internally) so the content hash is known before
// any file or database work happens: that lets the entire store be
// serialized, per hash, against a concurrent eviction of the same
// content below.
data, err := io.ReadAll(content)
if err != nil {
return "", fmt.Errorf("failed to read source content: %w", err)
}
sum := sha256.Sum256(data)
contentHash := ContentHash(hex.EncodeToString(sum[:]))
// Hold the content hash's lock for the whole store operation. A
// concurrent eviction of this exact hash (the real SHA-256 dedup
// case: a different source path whose bytes hash identically)
// deletes the accounting rows and unlinks the file inside the same
// lock, so the two can never interleave: either this store
// completes first (and a subsequent eviction removes it together
// with its rows and file, correctly), or eviction completes first
// (and this store finds the file already gone and recreates it
// fresh) — never a fresh row left pointing at a file eviction is
// mid-unlink on.
unlock := c.contentLocks.Lock(string(contentHash))
defer unlock()
size, err := c.srcContent.StoreHashed(contentHash, data)
if err != nil {
return "", fmt.Errorf("failed to store source content: %w", err)
}
// Store in database
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
headersJSON, err := json.Marshal(result.Headers)
if err != nil {
return "", fmt.Errorf("failed to marshal response headers: %w", err)
}
_, err = c.db.ExecContext(ctx, `
INSERT INTO source_content (content_hash, content_type, size_bytes)
VALUES (?, ?, ?)
ON CONFLICT(content_hash) DO NOTHING
`, contentHash, result.ContentType, size)
if err != nil {
return "", fmt.Errorf("failed to insert source content: %w", err)
}
_, err = c.db.ExecContext(ctx, `
INSERT INTO source_metadata
(source_host, source_path, source_query, path_hash,
content_hash, status_code, content_type, response_headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
content_hash = excluded.content_hash,
status_code = excluded.status_code,
content_type = excluded.content_type,
response_headers = excluded.response_headers,
fetched_at = CURRENT_TIMESTAMP
`, req.SourceHost, req.SourcePath, req.SourceQuery, pathHash,
contentHash, httpStatusOK, result.ContentType, string(headersJSON))
if err != nil {
return "", fmt.Errorf("failed to insert source metadata: %w", err)
}
c.writeMetadataSidecar(req, pathHash, contentHash, result)
c.notifyWritePressure()
return contentHash, nil
}
// StoreVariant stores a processed variant by its cache key and records
// it in the size accounting. On a disabled cache it is a no-op. The
// accounting insert is best-effort (the startup reconciliation pass
// adopts any variant file that misses its accounting row).
func (c *Cache) PutVariant(
ctx context.Context, cacheKey imagerequest.VariantKey, content io.Reader,
func (c *Cache) StoreVariant(
ctx context.Context, cacheKey VariantKey, content io.Reader, contentType string,
) error {
if c.config.MaxBytes == 0 {
if c.disabled {
return nil
}
size, err := c.variants.Store(cacheKey, content)
size, err := c.variants.Store(cacheKey, content, contentType)
if err != nil {
return err
}
c.metaCache.Add(cacheKey, contentType)
_, err = c.db.ExecContext(ctx, `
INSERT INTO variant_content (cache_key, size_bytes)
VALUES (?, ?)
INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
size_bytes = excluded.size_bytes,
content_type = excluded.content_type,
last_accessed_at = CURRENT_TIMESTAMP
`, string(cacheKey), size)
`, string(cacheKey), size, contentType)
if err != nil {
c.log.Warn("failed to record variant in size accounting",
"cache_key", cacheKey, "error", err)
@@ -212,128 +398,61 @@ func (c *Cache) PutVariant(
return nil
}
// Source opens the cached source of src and returns a reader and its size
// in bytes. A source whose row or file is missing is ErrNotFound, as is any
// source of a disabled cache. A source it opens has its access time handed
// to the background writer without waiting (see touchSourceContent).
func (c *Cache) Source(
ctx context.Context, src imagerequest.Source,
) (io.ReadCloser, int64, error) {
if c.config.MaxBytes == 0 {
return nil, 0, ErrNotFound
// LookupSource checks if we have cached source content for a request.
// Returns the content hash and content type if found, or empty values
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
// reports no cached source.
func (c *Cache) LookupSource(
ctx context.Context, req *ImageRequest,
) (ContentHash, string, error) {
if c.disabled {
return "", "", nil
}
key := src.Key()
var rowExists bool
var hashStr, contentType string
err := c.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
`, string(key)).Scan(&rowExists)
if err != nil {
return nil, 0, fmt.Errorf("failed to lookup source: %w", err)
}
if !rowExists {
return nil, 0, ErrNotFound
}
reader, size, err := c.srcContent.LoadWithSize(key)
if err != nil {
return nil, 0, err
}
c.touchSourceContent(key)
return reader, size, nil
}
// PutSource stores fetched source content: it writes the file named by
// the source's key, then the source's row. When the row cannot be written
// the file is removed and the store fails, as a file with no row is never
// served or evicted; if that removal fails too, the next reconciliation
// pass removes the file. On a disabled cache it is a no-op returning an
// empty key.
func (c *Cache) PutSource(
ctx context.Context,
src imagerequest.Source,
content io.Reader,
) (imagerequest.SourceKey, error) {
if c.config.MaxBytes == 0 {
return "", nil
}
data, err := io.ReadAll(content)
if err != nil {
return "", fmt.Errorf("failed to read source content: %w", err)
}
key := src.Key()
size, err := c.srcContent.Store(key, data)
if err != nil {
return "", fmt.Errorf("failed to store source content: %w", err)
}
_, err = c.db.ExecContext(ctx, `
INSERT INTO source_content (source_key, size_bytes)
VALUES (?, ?)
ON CONFLICT(source_key) DO UPDATE SET
size_bytes = excluded.size_bytes,
last_accessed_at = CURRENT_TIMESTAMP
`, string(key), size)
if err != nil {
deleteErr := c.srcContent.Delete(key)
if deleteErr != nil {
c.log.Warn("failed to remove source file whose row was not written",
"source_key", key, "error", deleteErr)
}
return "", fmt.Errorf("failed to insert source content: %w", err)
}
c.notifyWritePressure()
return key, nil
}
// Failure reports whether src is in the negative cache. It only reads: a
// row past its expiry is not a hit, and the eviction goroutine deletes it
// with the other expired rows (see CleanExpired).
func (c *Cache) Failure(
ctx context.Context, src imagerequest.Source,
) (bool, error) {
var expiresAt time.Time
err := c.db.QueryRowContext(ctx, `
SELECT expires_at FROM negative_cache
SELECT content_hash, content_type FROM source_metadata
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, src.Host, src.Path, src.Query).Scan(&expiresAt)
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&hashStr, &contentType)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
return "", "", nil
}
if err != nil {
return false, fmt.Errorf("failed to check negative cache: %w", err)
return "", "", fmt.Errorf("failed to lookup source: %w", err)
}
expired := time.Now().After(expiresAt)
contentHash := ContentHash(hashStr)
return !expired, nil
// Verify the content file exists
if !c.srcContent.Exists(contentHash) {
return "", "", nil
}
c.touchSourceContent(ctx, contentHash)
return contentHash, contentType, nil
}
// PutFailure stores a negative cache entry for src, whose fetch failed.
func (c *Cache) PutFailure(ctx context.Context, src imagerequest.Source) error {
// StoreNegative stores a negative cache entry for a failed fetch.
func (c *Cache) StoreNegative(
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
) error {
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
_, err := c.db.ExecContext(ctx, `
INSERT INTO negative_cache
(source_host, source_path, source_query, expires_at)
VALUES (?, ?, ?, ?)
(source_host, source_path, source_query, status_code,
error_message, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
status_code = excluded.status_code,
error_message = excluded.error_message,
fetched_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at
`, src.Host, src.Path, src.Query, expiresAt)
`, req.SourceHost, req.SourcePath, req.SourceQuery, statusCode, errMsg, expiresAt)
if err != nil {
return fmt.Errorf("failed to insert negative cache: %w", err)
}
@@ -341,6 +460,35 @@ func (c *Cache) PutFailure(ctx context.Context, src imagerequest.Source) error {
return nil
}
// GetSourceMetadataID returns the source metadata ID for a request.
func (c *Cache) GetSourceMetadataID(
ctx context.Context, req *ImageRequest,
) (int64, error) {
var id int64
err := c.db.QueryRowContext(ctx, `
SELECT id FROM source_metadata
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
if err != nil {
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
}
return id, nil
}
// GetSourceContent returns a reader for cached source content by its hash,
// and the content's size in bytes.
func (c *Cache) GetSourceContent(
contentHash ContentHash,
) (io.ReadCloser, int64, error) {
if c.disabled {
return nil, 0, ErrNotFound
}
return c.srcContent.LoadWithSize(contentHash)
}
// CleanExpired removes the negative cache rows past their expiry, one
// batch per statement, until none is left. Once ctx ends it starts no
// further batch and returns ctx's error. The eviction goroutine calls it
@@ -364,39 +512,266 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
}
}
// IncrementStats counts a cache hit or miss. It only adds to the cache's
// Prometheus counters in memory (see counters.go), so it never waits for the
// database or fails.
func (c *Cache) IncrementStats(hit bool) {
// Stats returns cache statistics.
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
var stats CacheStats
err := c.readHitAndMissCounts(ctx, &stats)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("failed to get cache stats: %w", err)
}
// Count and size the cached source images and processed variants from
// their tables. A disabled cache holds none, whatever rows an earlier
// run left.
if !c.disabled {
err = c.db.QueryRowContext(ctx, `
SELECT (SELECT COUNT(*) FROM source_content)
+ (SELECT COUNT(*) FROM variant_content)
`).Scan(&stats.TotalItems)
if err != nil {
c.log.Warn("failed to count cache items for stats", "error", err)
}
stats.TotalSizeBytes, err = c.sumContentSizeBytes(ctx)
if err != nil {
c.log.Warn("failed to sum cache size for stats", "error", err)
}
}
// Compute hit rate as a ratio
if stats.HitCount+stats.MissCount > 0 {
stats.HitRate = float64(stats.HitCount) / float64(stats.HitCount+stats.MissCount)
}
return &stats, nil
}
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
// methods, it writes to the database with ctx's deadline but not its
// cancellation, so an ended request is still counted but does not wait for
// the database past its deadline; a count not written by then becomes a
// pending count (see StartPendingCountWrites).
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
var err error
pendingCount := &c.pendingMisses
if hit {
c.cacheHits.Inc()
pendingCount = &c.pendingHits
_, err = c.db.ExecContext(countCtx, `
UPDATE cache_stats
SET hit_count = hit_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
} else {
c.cacheMisses.Inc()
_, err = c.db.ExecContext(countCtx, `
UPDATE cache_stats
SET miss_count = miss_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
}
switch {
case errors.Is(err, context.DeadlineExceeded):
c.addPendingCount(pendingCount, 1)
case err != nil:
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
}
c.IncrementUpstreamFetch(ctx, fetchBytes)
}
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
// A fetch that read no bytes is not counted.
func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
if fetchBytes <= 0 {
return
}
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
_, err := c.db.ExecContext(countCtx, `
UPDATE cache_stats
SET upstream_fetch_count = upstream_fetch_count + 1,
upstream_fetch_bytes = upstream_fetch_bytes + ?,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, fetchBytes)
switch {
case errors.Is(err, context.DeadlineExceeded):
c.addPendingCount(&c.pendingUpstreamFetches, 1)
c.addPendingCount(&c.pendingUpstreamFetchBytes, fetchBytes)
case err != nil:
c.log.Warn("failed to count upstream fetch",
"fetch_bytes", fetchBytes, "error", err)
}
}
// touchVariant hands the access time of a variant served from the cache to
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
// next batch. It never waits: while maxPendingAccessTimes access times of
// variants wait to be written, this one is dropped, and the variant only
// looks colder to eviction.
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
select {
case c.pendingVariantAccessTimes <- cacheKey:
default:
// IncrementTransformCount counts one image transcoded by the image processor.
func (c *Cache) IncrementTransformCount(ctx context.Context) {
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
_, err := c.db.ExecContext(countCtx, `
UPDATE cache_stats
SET transform_count = transform_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
switch {
case errors.Is(err, context.DeadlineExceeded):
c.addPendingCount(&c.pendingTransforms, 1)
case err != nil:
c.log.Warn("failed to count transform", "error", err)
}
}
// touchSourceContent hands the access time of a cached source a miss used to
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
// next batch. It never waits: while maxPendingAccessTimes access times of
// sources wait to be written, this one is dropped, and the source only looks
// colder to eviction.
func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
select {
case c.pendingSourceAccessTimes <- key:
default:
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
// cache_stats row plus the pending hits and misses (see
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
// write of the pending counts falls between the two reads.
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
c.pendingCountsWriteMutex.Lock()
defer c.pendingCountsWriteMutex.Unlock()
err := c.db.QueryRowContext(ctx, `
SELECT hit_count, miss_count
FROM cache_stats WHERE id = 1
`).Scan(&stats.HitCount, &stats.MissCount)
stats.HitCount += c.pendingHits.Load()
stats.MissCount += c.pendingMisses.Load()
return err
}
// loadVariantWithMeta is GetVariant for a variant metaCache does not
// hold: it reads the content type from the variant's .meta file and
// keeps it in metaCache, unless a StoreVariant has put one there
// meanwhile, as the store's is newer. A read that finds no .meta file,
// as one can when the store's .meta write failed or an eviction removed
// the file after the read opened the variant, serves
// application/octet-stream and keeps nothing, so metaCache only ever
// holds a type read from a .meta file or passed to StoreVariant.
func (c *Cache) loadVariantWithMeta(
cacheKey VariantKey,
) (io.ReadCloser, int64, string, error) {
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
if err != nil {
return nil, 0, "", err
}
if contentType == "" {
return reader, size, fallbackContentType, nil
}
c.metaCache.ContainsOrAdd(cacheKey, contentType)
return reader, size, contentType, nil
}
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
// A failure is logged and is otherwise non-fatal; the metadata is in the
// database.
func (c *Cache) writeMetadataSidecar(
req *ImageRequest,
pathHash PathHash,
contentHash ContentHash,
result *httpfetcher.FetchResult,
) {
meta := &SourceMetadata{
Host: req.SourceHost,
Path: req.SourcePath,
Query: req.SourceQuery,
ContentHash: string(contentHash),
StatusCode: result.StatusCode,
ContentType: result.ContentType,
ContentLength: result.ContentLength,
ResponseHeaders: result.Headers,
FetchedAt: time.Now().UTC().Unix(),
FetchDurationMs: result.FetchDurationMs,
RemoteAddr: result.RemoteAddr,
}
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta)
if err != nil {
c.log.Warn("failed to write metadata sidecar",
"host", req.SourceHost, "path_hash", pathHash, "error", err)
}
}
// touchVariant updates the LRU timestamp of a variant, best-effort:
// a failed touch only makes the entry look colder to eviction.
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
_, err := c.db.ExecContext(ctx, `
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
WHERE cache_key = ?
`, string(cacheKey))
if err != nil {
c.log.Debug("failed to touch variant LRU timestamp",
"cache_key", cacheKey, "error", err)
}
}
// touchSourceContent updates the LRU timestamp of a source content
// blob, best-effort: a failed touch only makes the blob look colder.
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
_, err := c.db.ExecContext(ctx, `
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
WHERE content_hash = ?
`, string(contentHash))
if err != nil {
c.log.Debug("failed to touch source content LRU timestamp",
"content_hash", contentHash, "error", err)
}
}
// checkNegativeCache checks if a request is in the negative cache.
func (c *Cache) checkNegativeCache(
ctx context.Context, req *ImageRequest,
) (bool, error) {
var expiresAt time.Time
err := c.db.QueryRowContext(ctx, `
SELECT expires_at FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check negative cache: %w", err)
}
// Check if expired
if time.Now().After(expiresAt) {
// Clean up expired entry
_, err = c.db.ExecContext(ctx, `
DELETE FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery)
if err != nil {
c.log.Warn("failed to delete expired negative cache entry",
"host", req.SourceHost, "path", req.SourcePath, "error", err)
}
return false, nil
}
return true, nil
}
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
@@ -1,214 +0,0 @@
package imgcache
import (
"bytes"
"errors"
"fmt"
"image/color"
"io"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
)
// TestVariant_ContentTypeFromFirstBytes stores an image in each output
// format as a variant and checks that Variant serves it whole with the
// content type of its format, read from the file.
func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
processor := imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
})
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
for _, format := range []imagerequest.Format{
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
} {
result, err := processor.Process(t.Context(), bytes.NewReader(source),
&imagerequest.Transform{
Size: imagerequest.Size{Width: 20, Height: 15},
Format: format,
Quality: 85,
FitMode: imagerequest.FitCover,
})
if err != nil {
t.Fatalf("making a %s image: %v", format, err)
}
image, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("reading the %s image: %v", format, err)
}
key := imagerequest.VariantKey("aabbccdd10" + string(format))
err = cache.PutVariant(t.Context(), key, bytes.NewReader(image))
if err != nil {
t.Fatalf("PutVariant(%s) error = %v", format, err)
}
reader, size, contentType, err := cache.Variant(key)
if err != nil {
t.Fatalf("Variant(%s) error = %v", format, err)
}
served, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil {
t.Fatalf("reading the %s variant: %v", format, err)
}
t.Logf("%s: %d bytes served as %s", format, size, contentType)
if want := imageprocessor.FormatToMIME(format); contentType != want {
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
}
if size != int64(len(image)) || !bytes.Equal(served, image) {
t.Errorf("%s variant served %d bytes, size %d, want the %d bytes stored",
format, len(served), size, len(image))
}
}
}
// TestVariant_RefusesFileOfNoOutputFormat checks that Variant refuses
// a variant file whose first bytes are not those of an output format, with
// an error other than ErrNotFound, and that a variant with no file, or any
// variant of a disabled cache, is ErrNotFound.
func TestVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
tests := []struct {
name string
content []byte
wantErr error
}{
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
errNotAnOutputFormat},
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
{"empty", nil, io.EOF},
}
for i, tt := range tests {
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
_, err := cache.variants.Store(key, bytes.NewReader(tt.content))
if err != nil {
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
}
reader, _, contentType, err := cache.Variant(key)
t.Logf("%s: Variant() error = %v", tt.name, err)
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
t.Errorf("%s: Variant() = reader %v, content type %q, error %v; "+
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
}
}
reader, _, _, err := cache.Variant(testVariantKeyFour)
if reader != nil || !errors.Is(err, ErrNotFound) {
t.Errorf("Variant() of a variant with no file = reader %v, error %v; "+
"want no reader and %v", reader, err, ErrNotFound)
}
disabled, _ := newEvictionTestCache(t, 0)
reader, _, _, err = disabled.Variant(testVariantKeyOne)
if reader != nil || !errors.Is(err, ErrNotFound) {
t.Errorf("Variant() on a disabled cache = reader %v, error %v; "+
"want no reader and %v", reader, err, ErrNotFound)
}
}
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
// more access time of a variant, and one more of a source, than may wait to
// be written. touchVariant and touchSourceContent must return each time
// without waiting, and the last of each must be dropped.
func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
done := make(chan struct{})
go func() {
defer close(done)
for range maxPendingAccessTimes + 1 {
cache.touchVariant(testVariantKeyOne)
cache.touchSourceContent("aabbccdd3001")
}
}()
select {
case <-done:
case <-time.After(pendingAccessTimesWait):
t.Fatal("touchVariant or touchSourceContent waited once the access " +
"times reached the limit")
}
if waiting := len(cache.pendingVariantAccessTimes); waiting != maxPendingAccessTimes {
t.Errorf("%d access times of variants wait to be written, want %d",
waiting, maxPendingAccessTimes)
}
if waiting := len(cache.pendingSourceAccessTimes); waiting != maxPendingAccessTimes {
t.Errorf("%d access times of sources wait to be written, want %d",
waiting, maxPendingAccessTimes)
}
}
// TestStopPendingAccessTimeWrites_WritesPendingAccessTimes hands the access
// time of one of two variants, three times, then stops the goroutine that
// writes them, which never started. The stop must write that access time and
// leave the other variant's.
func TestStopPendingAccessTimeWrites_WritesPendingAccessTimes(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
anHourAgo := time.Now().Add(-time.Hour)
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
setVariantLastAccessed(t, cache, key, anHourAgo)
}
for range 3 {
cache.touchVariant(testVariantKeyOne)
}
err := cache.StopPendingAccessTimeWrites(t.Context())
if err != nil {
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
}
for key, wantTouched := range map[imagerequest.VariantKey]int{
testVariantKeyOne: 1, testVariantKeyTwo: 0,
} {
touched := countRows(t, cache, `
SELECT COUNT(*) FROM variant_content
WHERE cache_key = ? AND last_accessed_at > ?
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat))
if touched != wantTouched {
t.Errorf("variant %s: %d rows with a new access time, want %d",
key, touched, wantTouched)
}
}
if waiting := len(cache.pendingVariantAccessTimes); waiting != 0 {
t.Errorf("%d access times still wait to be written, want 0", waiting)
}
}
+415 -11
View File
@@ -1,14 +1,15 @@
package imgcache
import (
"bytes"
"context"
"database/sql"
"os"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
_ "modernc.org/sqlite"
"sneak.berlin/go/pixa/internal/httpfetcher"
)
func setupTestDB(t *testing.T) *sql.DB {
@@ -22,18 +23,67 @@ func setupTestDB(t *testing.T) *sql.DB {
// Create schema
schema := `
CREATE TABLE source_content (
source_key TEXT PRIMARY KEY,
content_hash TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE source_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_host TEXT NOT NULL,
source_path TEXT NOT NULL,
source_query TEXT NOT NULL DEFAULT '',
path_hash TEXT NOT NULL,
content_hash TEXT,
status_code INTEGER NOT NULL,
content_type TEXT,
response_headers TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
etag TEXT,
last_modified TEXT,
UNIQUE(source_host, source_path, source_query)
);
CREATE TABLE output_content (
content_hash TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE request_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT NOT NULL UNIQUE,
source_metadata_id INTEGER NOT NULL,
output_hash TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 85,
fit_mode TEXT NOT NULL DEFAULT 'cover',
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
access_count INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE negative_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_host TEXT NOT NULL,
source_path TEXT NOT NULL,
source_query TEXT NOT NULL DEFAULT '',
status_code INTEGER NOT NULL,
error_message TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
UNIQUE(source_host, source_path, source_query)
);
CREATE TABLE cache_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
hit_count INTEGER NOT NULL DEFAULT 0,
miss_count INTEGER NOT NULL DEFAULT 0,
upstream_fetch_count INTEGER NOT NULL DEFAULT 0,
upstream_fetch_bytes INTEGER NOT NULL DEFAULT 0,
transform_count INTEGER NOT NULL DEFAULT 0,
last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO cache_stats (id) VALUES (1);
`
_, err = db.ExecContext(t.Context(), schema)
@@ -44,25 +94,379 @@ func setupTestDB(t *testing.T) *sql.DB {
return db
}
func setupTestCache(t *testing.T) *Cache {
t.Helper()
tmpDir := t.TempDir()
db := setupTestDB(t)
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
return cache
}
func TestCache_LookupMiss(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
result, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if result.Hit {
t.Error("Lookup() hit = true, want false for cache miss")
}
if result.CacheStatus != CacheMiss {
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheMiss)
}
}
func TestCache_StoreAndLookup(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
// Store source content
sourceContent := []byte("fake jpeg data")
fetchResult := &httpfetcher.FetchResult{
ContentType: testContentTypeJPEG,
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
}
contentHash, err := cache.StoreSource(
ctx, req, bytes.NewReader(sourceContent), fetchResult)
if err != nil {
t.Fatalf("StoreSource() error = %v", err)
}
if contentHash == "" {
t.Error("StoreSource() returned empty hash")
}
// Store variant
cacheKey := CacheKey(req)
outputContent := []byte("fake webp data")
err = cache.StoreVariant(
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil {
t.Fatalf("StoreVariant() error = %v", err)
}
// Now lookup should hit
result, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !result.Hit {
t.Error("Lookup() hit = false, want true after store")
}
if result.CacheStatus != CacheHit {
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheHit)
}
if result.CacheKey == "" {
t.Error("Lookup() returned empty cache key")
}
}
func TestCache_NegativeCache(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: "/photos/notfound.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}
// Store negative cache entry
err := cache.StoreNegative(ctx, req, 404, "not found")
if err != nil {
t.Fatalf("StoreNegative() error = %v", err)
}
// Lookup should return miss (negative cache is checked at service level)
result, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if result.Hit {
t.Error("Lookup() hit = true, want false for negative cache entry")
}
}
func TestCache_NegativeCacheExpiry(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
db := setupTestDB(t)
// Very short negative TTL for testing
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
NegativeTTL: 1 * time.Millisecond,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: "/photos/expired.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}
// Store negative cache entry
err = cache.StoreNegative(ctx, req, 404, "not found")
if err != nil {
t.Fatalf("StoreNegative() error = %v", err)
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Lookup should return miss after expiry
result, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() error = %v, want nil after expiry", err)
}
if result.Hit {
t.Error("Lookup() hit = true, want false after negative cache expiry")
}
}
func TestCache_VariantLookup(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: "/photos/variant.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
// Store variant
cacheKey := CacheKey(req)
outputContent := []byte("output data")
err := cache.StoreVariant(
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil {
t.Fatalf("StoreVariant() error = %v", err)
}
// First lookup
result1, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() first error = %v", err)
}
if !result1.Hit {
t.Error("Lookup() first hit = false")
}
// Second lookup should also hit (from disk)
result2, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() second error = %v", err)
}
if !result2.Hit {
t.Error("Lookup() second hit = false")
}
if result1.CacheKey != result2.CacheKey {
t.Error("Lookup() returned different cache keys")
}
}
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: "/photos/variantct.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
// Store variant
cacheKey := CacheKey(req)
outputContent := []byte("output webp data")
err := cache.StoreVariant(
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil {
t.Fatalf("StoreVariant() error = %v", err)
}
// Lookup
result, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !result.Hit {
t.Fatal("Lookup() hit = false, want true")
}
// GetVariant should return content type
reader, size, contentType, err := cache.GetVariant(result.CacheKey)
if err != nil {
t.Fatalf("GetVariant() error = %v", err)
}
defer func() { _ = reader.Close() }()
if contentType != "image/webp" {
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
}
if size != int64(len(outputContent)) {
t.Errorf("GetVariant() size = %d, want %d", size, len(outputContent))
}
}
func TestCache_GetVariant(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
req := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: "/photos/output.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
// Store variant
cacheKey := CacheKey(req)
outputContent := []byte("the actual output content")
err := cache.StoreVariant(
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil {
t.Fatalf("StoreVariant() error = %v", err)
}
// Lookup to get cache key
result, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
// Get variant content
reader, _, _, err := cache.GetVariant(result.CacheKey)
if err != nil {
t.Fatalf("GetVariant() error = %v", err)
}
defer func() { _ = reader.Close() }()
buf := make([]byte, 100)
n, _ := reader.Read(buf)
if !bytes.Equal(buf[:n], outputContent) {
t.Errorf("GetVariant() content = %q, want %q", buf[:n], outputContent)
}
}
func TestCache_Stats(t *testing.T) {
t.Parallel()
cache := setupTestCache(t)
ctx := context.Background()
// Increment some stats
cache.IncrementStats(ctx, true, 0)
cache.IncrementStats(ctx, true, 0)
cache.IncrementStats(ctx, false, 1024)
stats, err := cache.Stats(ctx)
if err != nil {
t.Fatalf("Stats() error = %v", err)
}
if stats.HitCount != 2 {
t.Errorf("Stats() HitCount = %d, want 2", stats.HitCount)
}
if stats.MissCount != 1 {
t.Errorf("Stats() MissCount = %d, want 1", stats.MissCount)
}
}
func TestCache_CleanExpired(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
db := setupTestDB(t)
cache, _ := NewCache(db, Config{
cache, _ := NewCache(db, CacheConfig{
StateDir: tmpDir,
NegativeTTL: 1 * time.Millisecond,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
})
ctx := context.Background()
// Insert expired negative cache entry directly
_, err := db.ExecContext(ctx, `
INSERT INTO negative_cache
(source_host, source_path, source_query, expires_at)
VALUES ('example.com', '/old.jpg', '', datetime('now', '-1 hour'))
(source_host, source_path, source_query, status_code, expires_at)
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
`)
if err != nil {
t.Fatalf("failed to insert test data: %v", err)
@@ -103,10 +507,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
tmpDir := t.TempDir()
db := setupTestDB(t)
_, err := NewCache(db, Config{
_, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
})
if err != nil {
t.Fatalf("NewCache() error = %v", err)
}
@@ -115,6 +518,7 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
dirs := []string{
"cache/sources",
"cache/variants",
"cache/metadata",
}
for _, dir := range dirs {
+9 -14
View File
@@ -5,8 +5,6 @@ import (
"strconv"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
@@ -31,10 +29,10 @@ func TestEvictionReadsTheUsageTotal(t *testing.T) {
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
bytes.Repeat([]byte{0x75}, 200))
// Storing a source URL again changes its size, so the total must
// follow a statement that changes it.
// pixa never changes a source's size, but a statement that does must
// change the total too.
_, err := cache.db.ExecContext(ctx,
`UPDATE source_content SET size_bytes = 900 WHERE source_key = ?`,
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
string(kept))
if err != nil {
t.Fatalf("failed to change the source's size: %v", err)
@@ -155,9 +153,8 @@ func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
// pass. No read the pass makes of a content table, to check its rows or
// to sum them, may cover more than two rows, so a request's query waits
// for one page at most. Between them the reads must still cover every
// variant row twice, once to check it and once to sum it, and every
// source row once, as source rows are read only to sum them, and the sum
// must put a wrong total right.
// row of both tables twice, once to check it and once to sum it, and the
// sum must put a wrong total right.
func TestReconciliationReadsAPageAtATime(t *testing.T) {
t.Parallel()
@@ -174,8 +171,7 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
storeEvictionTestSource(t, cache, "pages.example.com",
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
storeEvictionTestVariant(t, cache,
imagerequest.VariantKey("aabbccdd000"+strconv.Itoa(i)),
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)),
bytes.Repeat(content, 10))
}
@@ -208,10 +204,9 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
coveredRows += rows
}
// Five variant rows, each read once to check it and once to sum it,
// and five source rows, read only to sum them.
if coveredRows != 15 {
t.Errorf("the reads covered %d rows in all, want 15", coveredRows)
// Ten rows, each read once to check it and once to sum it.
if coveredRows != 20 {
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
}
usage, err := cache.UsageBytes(ctx)
+85
View File
@@ -0,0 +1,85 @@
package imgcache
import (
"context"
"errors"
"fmt"
"math"
"path/filepath"
"syscall"
)
// DefaultCacheMaxBytesFloor is the minimum computed default for the
// cache_max_bytes setting: 500 MiB. The floor applies only to the
// computed default (when the key is omitted from the configuration),
// never to explicitly configured values.
const DefaultCacheMaxBytesFloor int64 = 524288000
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
// the 75% share used for the computed default limit as integer
// arithmetic (dividing before multiplying avoids overflow).
const (
freeSpaceFractionNumerator uint64 = 3
freeSpaceFractionDenominator uint64 = 4
)
var errNegativeBlockSize = errors.New("statfs reported negative block size")
// FreeSpaceProbeFunc reports the number of free bytes available on the
// filesystem containing path. It is a function type so tests can
// inject a fake probe instead of depending on the host disk.
type FreeSpaceProbeFunc func(path string) (uint64, error)
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
// the given path, as available to unprivileged processes.
func defaultFreeSpaceProbe(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
if stat.Bsize < 0 {
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
}
blockSize := uint64(stat.Bsize)
return stat.Bavail * blockSize, nil
}
// computeDefaultMaxBytes returns the default cache size limit: 75% of
// the sum of the free bytes probe reports for <state_dir>/cache/ and
// the bytes the cache already holds, with a floor of
// DefaultCacheMaxBytesFloor. Counting what the cache holds keeps the
// limit from shrinking as the cache fills.
func (c *Cache) computeDefaultMaxBytes(
ctx context.Context, probe FreeSpaceProbeFunc,
) (int64, error) {
cacheDir := filepath.Join(c.config.StateDir, "cache")
freeBytes, err := probe(cacheDir)
if err != nil {
return 0, fmt.Errorf(
"default cache_max_bytes: cannot determine free space for %q: %w",
cacheDir, err)
}
usedBytes, err := c.UsageBytes(ctx)
if err != nil {
return 0, err
}
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
//nolint:gosec // G115: UsageBytes returns the total cache usage, never negative
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
computed = min(computed, math.MaxInt64)
limit := int64(computed)
limit = max(limit, DefaultCacheMaxBytesFloor)
return limit, nil
}
@@ -0,0 +1,188 @@
package imgcache
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// Static errors returned by the stub free-space probes below.
var (
errTestStatfsFailed = errors.New("statfs failed")
errTestProbeNotExpected = errors.New("probe must not be called")
)
// TestComputeDefaultMaxBytesCountsWhatTheCacheHolds verifies that the
// default limit is 75% of the free space plus what the cache already
// holds, so a cache filled to its limit keeps that limit across a
// restart instead of shrinking to 75% of the space left free.
func TestComputeDefaultMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
// Empty cache, 4 GiB free -> 3 GiB default.
got, err := cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return 4294967296, nil })
if err != nil {
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
}
t.Logf("default for an empty cache with 4 GiB free: %d", got)
if got != 3221225472 {
t.Errorf("default for an empty cache = %d, want 3221225472 (75%% of 4 GiB)",
got)
}
// The cache now holds those 3 GiB, which leaves 1 GiB free.
_, err = cache.db.ExecContext(t.Context(),
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)`,
string(testVariantKeyOne), 3221225472, testContentTypeWebP,
)
if err != nil {
t.Fatalf("failed to insert variant accounting row: %v", err)
}
got, err = cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return 1073741824, nil })
if err != nil {
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
}
t.Logf("default for a cache holding 3 GiB with 1 GiB free: %d", got)
if got != 3221225472 {
t.Errorf("default for a cache holding 3 GiB with 1 GiB free = %d, "+
"want 3221225472 (75%% of 1 GiB + 3 GiB)", got)
}
}
// TestComputeDefaultMaxBytesAppliesFloor verifies that when 75% of the
// free space plus what the cache holds is below 500 MiB, the default
// is floored at DefaultCacheMaxBytesFloor.
func TestComputeDefaultMaxBytesAppliesFloor(t *testing.T) {
t.Parallel()
cases := []struct {
name string
freeBytes uint64
}{
{name: "100 MiB free", freeBytes: 104857600},
{name: "zero free", freeBytes: 0},
{name: "just below floor threshold", freeBytes: 699050665},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
got, err := cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return tc.freeBytes, nil })
if err != nil {
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
}
if got != DefaultCacheMaxBytesFloor {
t.Errorf("computeDefaultMaxBytes = %d, want floor %d",
got, DefaultCacheMaxBytesFloor)
}
})
}
}
// TestComputeDefaultMaxBytesPropagatesProbeError verifies that a
// failing free-space probe produces an error naming cache_max_bytes,
// instead of a silently wrong default.
func TestComputeDefaultMaxBytesPropagatesProbeError(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
_, err := cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return 0, errTestStatfsFailed })
if err == nil {
t.Fatal("probe failure must produce an error, got nil")
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "cache_max_bytes") {
t.Errorf("error %q does not name cache_max_bytes", err.Error())
}
}
// TestNewCacheComputesDefaultMaxBytesWhenAsked verifies that with
// UseDefaultMaxBytes set, the cache's limit becomes the computed
// default, and that the probe is pointed at <state_dir>/cache/, which
// must be created first so statfs measures the right filesystem.
func TestNewCacheComputesDefaultMaxBytesWhenAsked(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
wantCacheDir := filepath.Join(stateDir, "cache")
var probedPath string
// 4 GiB free -> 3 GiB default.
probe := func(path string) (uint64, error) {
probedPath = path
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
path, info, err)
}
return 4294967296, nil
}
cache, err := newCache(evictionTestDB(t), CacheConfig{
StateDir: stateDir,
UseDefaultMaxBytes: true,
}, probe)
if err != nil {
t.Fatalf("newCache returned error: %v", err)
}
if cache.config.MaxBytes != 3221225472 {
t.Errorf("MaxBytes = %d, want computed default 3221225472",
cache.config.MaxBytes)
}
if probedPath != wantCacheDir {
t.Errorf("free space probed at %q, want cache directory %q",
probedPath, wantCacheDir)
}
}
// TestNewCacheKeepsExplicitMaxBytes verifies that without
// UseDefaultMaxBytes the cache keeps MaxBytes exactly as given and
// never consults the free-space probe.
func TestNewCacheKeepsExplicitMaxBytes(t *testing.T) {
t.Parallel()
probe := func(string) (uint64, error) {
t.Error("free-space probe must not be consulted for explicit values")
return 0, errTestProbeNotExpected
}
cache, err := newCache(evictionTestDB(t), CacheConfig{
StateDir: t.TempDir(),
MaxBytes: 1024,
}, probe)
if err != nil {
t.Fatalf("newCache returned error: %v", err)
}
if cache.config.MaxBytes != 1024 {
t.Errorf("MaxBytes = %d, want explicit 1024 (no floor, no recompute)",
cache.config.MaxBytes)
}
}
@@ -1,4 +1,4 @@
package imageproxy
package imgcache
import (
"bytes"
@@ -16,7 +16,6 @@ import (
"github.com/getsentry/sentry-go"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
)
@@ -36,7 +35,7 @@ type heldFetcher struct {
}
func (f *heldFetcher) Fetch(
ctx context.Context, source imagerequest.Source,
ctx context.Context, url string,
) (*httpfetcher.FetchResult, error) {
f.fetches.Add(1)
@@ -48,7 +47,7 @@ func (f *heldFetcher) Fetch(
return nil, ctx.Err()
}
return f.upstream.Fetch(ctx, source)
return f.upstream.Fetch(ctx, url)
}
// releaseFetches lets every held fetch, and every later one, go on.
@@ -56,15 +55,15 @@ func (f *heldFetcher) releaseFetches() {
f.releaseOnce.Do(func() { close(f.release) })
}
// setupHeldFetchProxy returns a test proxy whose fetches go through a
// setupHeldFetchService returns a test service whose fetches go through a
// heldFetcher. Its database is limited to one connection: each connection to
// an in-memory SQLite database opens a new, empty one, so requests running at
// once must share the connection that holds the schema.
func setupHeldFetchProxy(t *testing.T) (*testProxy, *TestFixtures, *heldFetcher) {
func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher) {
t.Helper()
svc, fixtures := setupTestProxy(t)
svc.db.SetMaxOpenConns(1)
svc, fixtures := SetupTestService(t)
svc.cache.db.SetMaxOpenConns(1)
fetcher := &heldFetcher{
upstream: svc.fetcher,
@@ -79,22 +78,16 @@ func setupHeldFetchProxy(t *testing.T) (*testProxy, *TestFixtures, *heldFetcher)
}
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
// given quality and fit mode. Each call returns a new request, which a test
// may change.
func photoVariant(
fixtures *TestFixtures, quality int, fit imagerequest.Fit,
) *imagerequest.Request {
return &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
Path: testPathPhoto,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 25},
Format: imagerequest.FormatJPEG,
Quality: quality,
FitMode: fit,
},
// given quality and fit mode. Each call returns a new request, as Get writes
// to the request it is given.
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest {
return &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 25},
Format: FormatJPEG,
Quality: quality,
FitMode: fit,
}
}
@@ -107,7 +100,7 @@ type getResult struct {
// startGet calls Get in a goroutine of its own and delivers what it returned
// on the channel.
func startGet(
ctx context.Context, svc *testProxy, req *imagerequest.Request,
ctx context.Context, svc *Service, req *ImageRequest,
) <-chan getResult {
results := make(chan getResult, 1)
@@ -139,21 +132,21 @@ func jpegSize(data []byte) (int, int) {
return config.Width, config.Height
}
// TestProxy_Get_ConcurrentMissesShareOneFetch starts several requests for
// TestService_Get_ConcurrentMissesShareOneFetch starts several requests for
// one uncached variant while the first one's fetch is held. Between them they
// must fetch the source once and transcode it once, every one must be answered
// with the same 50x25 JPEG, and each must count one miss.
func TestProxy_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
const requests = 8
pending := make([]<-chan getResult, 0, requests)
for range requests {
pending = append(pending,
startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover)))
startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)))
}
<-fetcher.started
@@ -183,7 +176,7 @@ func TestProxy_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
t.Errorf("%d requests made %d upstream fetches, want 1", requests, fetches)
}
// NewTestFS builds the same files the test proxy's fetcher serves.
// NewTestFS builds the same files the test service's fetcher serves.
testFS, _ := NewTestFS(t)
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
@@ -191,26 +184,24 @@ func TestProxy_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
t.Fatal(err)
}
want := counterValues{0, requests, 1, int64(len(photo)), 1}
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
if got := readCounters(t, svc); got != want {
if got := readCacheStatsCounters(t, svc.cache); got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
}
// TestProxy_Get_ConcurrentVariantsStayApart requests three variants of the
// TestService_Get_ConcurrentVariantsStayApart requests three variants of the
// test photo at once that differ only in quality or fit. Each must be made by
// a fetch and a transcode of its own, and each answer must be its own variant.
func TestProxy_Get_ConcurrentVariantsStayApart(t *testing.T) {
func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
lowQuality := startGet(t.Context(), svc,
photoVariant(fixtures, 40, imagerequest.FitCover))
contain := startGet(t.Context(), svc,
photoVariant(fixtures, 85, imagerequest.FitContain))
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover))
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitContain))
for range 3 {
select {
@@ -253,21 +244,21 @@ func TestProxy_Get_ConcurrentVariantsStayApart(t *testing.T) {
}
}
// TestProxy_Get_WaiterStopsWhenItsContextEnds has a second request for a
// TestService_Get_WaiterStopsWhenItsContextEnds has a second request for a
// variant join the first one's held fetch, then ends the second request's
// context. The second request must return at once with the context's error,
// while the fetch is still held, and the first must still be answered.
func TestProxy_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
<-fetcher.started
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, FitCover))
time.Sleep(arrivalWait)
cancelWaiter()
@@ -298,22 +289,22 @@ func TestProxy_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
}
}
// TestProxy_Get_FirstRequestLeavingKeepsTheWork ends the context of the
// TestService_Get_FirstRequestLeavingKeepsTheWork ends the context of the
// request whose fetch is held, after a second request has joined it. The fetch
// and transcode must go on and answer the second request. The first request
// waits for its own work, as every request did before misses were shared, and
// is answered too.
func TestProxy_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
firstCtx, cancelFirst := context.WithCancel(t.Context())
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
<-fetcher.started
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
time.Sleep(arrivalWait)
cancelFirst()
@@ -337,11 +328,12 @@ func TestProxy_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
}
}
// TestProxy_Get_ConcurrentMissesShareAFailure has several requests for an
// TestService_Get_ConcurrentMissesShareAFailure has several requests for an
// image that cannot be served arrive while its fetch is held: the one fetch
// answers all of them with its error. The request after them is answered from
// the negative cache, where the failure is kept.
func TestProxy_Get_ConcurrentMissesShareAFailure(t *testing.T) {
// the negative cache when the failure is kept there, and fetches again when it
// is not.
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -354,19 +346,19 @@ func TestProxy_Get_ConcurrentMissesShareAFailure(t *testing.T) {
{"upstream answers 404, kept in the negative cache",
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
ErrNegativeCached, 1},
{"source fails the magic byte check, kept in the negative cache",
"/images/text.png", magic.ErrUnknownFormat, ErrNegativeCached, 1},
{"source fails the magic byte check, not kept",
"/images/text.png", magic.ErrUnknownFormat, magic.ErrUnknownFormat, 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
request := func() *imagerequest.Request {
req := photoVariant(fixtures, 85, imagerequest.FitCover)
req.Source.Path = tc.path
request := func() *ImageRequest {
req := photoVariant(fixtures, 85, FitCover)
req.SourcePath = tc.path
return req
}
@@ -398,17 +390,17 @@ func TestProxy_Get_ConcurrentMissesShareAFailure(t *testing.T) {
}
}
// TestProxy_Get_EndedRequestFetchesNothing checks that a request whose
// TestService_Get_EndedRequestFetchesNothing checks that a request whose
// context has already ended when it misses the cache starts no fetch.
func TestProxy_Get_EndedRequestFetchesNothing(t *testing.T) {
func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err := svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
_, err := svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
if !errors.Is(err, context.Canceled) {
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
}
@@ -418,21 +410,21 @@ func TestProxy_Get_EndedRequestFetchesNothing(t *testing.T) {
}
}
// TestProxy_Get_ReturnsByItsDeadline gives a request a deadline and holds
// TestService_Get_ReturnsByItsDeadline gives a request a deadline and holds
// its fetch until the fetch's context ends, as the fetcher does while it waits
// for a free connection to the host. The request must return by its deadline
// with the deadline's error.
func TestProxy_Get_ReturnsByItsDeadline(t *testing.T) {
func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
t.Parallel()
svc, fixtures, _ := setupHeldFetchProxy(t)
svc, fixtures, _ := setupHeldFetchService(t)
const timeout = 200 * time.Millisecond
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
select {
case got := <-results:
@@ -444,18 +436,18 @@ func TestProxy_Get_ReturnsByItsDeadline(t *testing.T) {
}
}
// TestProxy_Get_WaiterGetsProcessingTimedOut has a request with no deadline
// TestService_Get_WaiterGetsProcessingTimedOut has a request with no deadline
// join the held fetch of a first request with a short one. With the only
// processing slot held, the first request's transcode waits for it until the
// first request's deadline ends, and stops then. The waiting request, whose
// own context has not ended, must get ErrProcessingTimedOut, which the image
// routes answer 503 as when pixa is busy.
func TestProxy_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchProxy(t)
svc, fixtures, fetcher := setupHeldFetchService(t)
svc.processor = imageprocessor.New(
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
imageprocessor.Params{MaxConcurrentProcessing: 1},
)
holdProcessingSlot(t, svc.processor)
@@ -465,11 +457,11 @@ func TestProxy_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
defer cancelFirst()
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
<-fetcher.started
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
time.Sleep(arrivalWait)
fetcher.releaseFetches()
@@ -489,18 +481,18 @@ func TestProxy_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
type panickingFetcher struct{}
func (panickingFetcher) Fetch(
context.Context, imagerequest.Source,
context.Context, string,
) (*httpfetcher.FetchResult, error) {
panic("upstream fetcher panicked")
}
// TestProxy_Get_PanicBecomesAnError checks that a panic while a variant is
// TestService_Get_PanicBecomesAnError checks that a panic while a variant is
// being made reaches its request as an error naming the panic, instead of
// being raised again.
func TestProxy_Get_PanicBecomesAnError(t *testing.T) {
func TestService_Get_PanicBecomesAnError(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
svc, fixtures := SetupTestService(t)
svc.fetcher = panickingFetcher{}
var err error
@@ -512,7 +504,7 @@ func TestProxy_Get_PanicBecomesAnError(t *testing.T) {
}
}()
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
}()
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
@@ -520,13 +512,13 @@ func TestProxy_Get_PanicBecomesAnError(t *testing.T) {
}
}
// TestProxy_Get_PanicIsReportedToSentry checks that a panic while a variant
// TestService_Get_PanicIsReportedToSentry checks that a panic while a variant
// is being made is reported through the Sentry hub on the request's context,
// where the Sentry middleware puts one when sentry_dsn is set.
func TestProxy_Get_PanicIsReportedToSentry(t *testing.T) {
func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
svc, fixtures := SetupTestService(t)
svc.fetcher = panickingFetcher{}
transport := &sentry.MockTransport{}
@@ -549,7 +541,7 @@ func TestProxy_Get_PanicIsReportedToSentry(t *testing.T) {
}
}()
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
}()
events := transport.Events()
+64
View File
@@ -0,0 +1,64 @@
package imgcache
import "sync"
// contentLock provides per-key mutual exclusion for content-hash keyed
// operations. StoreSource and evictSourceBlob each hold a content
// hash's lock for the full duration of their file-plus-accounting-row
// work, so a store and an eviction racing on identical content bytes
// (the real SHA-256 content-addressed dedup case, not a contrived one)
// can never interleave: the unlink of an evicted blob's file can never
// race the creation of a fresh database row for a concurrently
// re-stored copy of the same content. Entries are removed once no
// goroutine holds or is waiting for them, so a long-running process
// does not accumulate memory proportional to the number of distinct
// content hashes it has ever seen.
type contentLock struct {
mu sync.Mutex
entries map[string]*contentLockEntry
}
// contentLockEntry is one key's exclusion lock plus a count of
// goroutines currently holding or waiting to acquire it, used to know
// when it is safe to remove the entry from the map.
type contentLockEntry struct {
mu sync.Mutex
count int
}
// newContentLock creates an empty contentLock.
func newContentLock() *contentLock {
return &contentLock{entries: make(map[string]*contentLockEntry)}
}
// Lock acquires exclusive access for key, blocking until it is
// available, and returns a function that releases it. The caller must
// invoke the returned function exactly once to release the lock.
func (c *contentLock) Lock(key string) func() {
c.mu.Lock()
entry, ok := c.entries[key]
if !ok {
entry = &contentLockEntry{}
c.entries[key] = entry
}
entry.count++
c.mu.Unlock()
entry.mu.Lock()
return func() {
entry.mu.Unlock()
c.mu.Lock()
entry.count--
if entry.count == 0 {
delete(c.entries, key)
}
c.mu.Unlock()
}
}
@@ -0,0 +1,142 @@
package imgcache
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// TestContentLockExcludesSameKey verifies that two goroutines locking
// the same key never run their critical sections concurrently.
func TestContentLockExcludesSameKey(t *testing.T) {
t.Parallel()
lock := newContentLock()
var (
active atomic.Int32
maxSeen int32
wg sync.WaitGroup
)
const goroutines = 20
wg.Add(goroutines)
for range goroutines {
go func() {
defer wg.Done()
unlock := lock.Lock("same-key")
defer unlock()
n := active.Add(1)
for {
seen := atomic.LoadInt32(&maxSeen)
if n <= seen || atomic.CompareAndSwapInt32(&maxSeen, seen, n) {
break
}
}
time.Sleep(time.Millisecond)
active.Add(-1)
}()
}
wg.Wait()
if maxSeen != 1 {
t.Errorf("max concurrent holders of the same key = %d, want 1", maxSeen)
}
}
// TestContentLockAllowsDifferentKeys verifies that locking distinct
// keys does not serialize unrelated work: all goroutines must be able
// to enter their critical sections at once, proven by every one of
// them reaching the rendezvous point before any is allowed to
// proceed.
func TestContentLockAllowsDifferentKeys(t *testing.T) {
t.Parallel()
lock := newContentLock()
const goroutines = 20
var (
wg sync.WaitGroup
inside atomic.Int32
reached = make(chan struct{}, goroutines)
)
wg.Add(goroutines)
release := make(chan struct{})
for i := range goroutines {
key := string(rune('a' + i))
go func() {
defer wg.Done()
unlock := lock.Lock(key)
defer unlock()
inside.Add(1)
reached <- struct{}{}
<-release
}()
}
// Every goroutine must reach the rendezvous point (i.e. acquire its
// own key's lock) without needing any other to release first. If
// keys were incorrectly serialized onto one underlying lock, only
// one would get here and this would time out.
for i := range goroutines {
select {
case <-reached:
case <-time.After(2 * time.Second):
t.Fatalf("only %d/%d goroutines locking distinct keys made progress; "+
"keys may be incorrectly serialized", i, goroutines)
}
}
if n := inside.Load(); n != goroutines {
t.Errorf("goroutines inside their critical section = %d, want %d",
n, goroutines)
}
close(release)
wg.Wait()
}
// TestContentLockRemovesEntryAfterUnlock verifies that the internal
// entries map does not grow without bound: once no goroutine holds or
// awaits a key, its entry is removed.
func TestContentLockRemovesEntryAfterUnlock(t *testing.T) {
t.Parallel()
lock := newContentLock()
unlock := lock.Lock("k")
lock.mu.Lock()
if _, ok := lock.entries["k"]; !ok {
lock.mu.Unlock()
t.Fatal("entry missing while lock is held")
}
lock.mu.Unlock()
unlock()
lock.mu.Lock()
defer lock.mu.Unlock()
if _, ok := lock.entries["k"]; ok {
t.Error("entry for key still present after the last holder unlocked")
}
}
-31
View File
@@ -1,31 +0,0 @@
package imgcache
import (
"fmt"
"github.com/prometheus/client_golang/prometheus"
)
// registerCounters makes the cache's counters and registers them on
// metricsRegistry. The counters are kept in memory, so adding to one waits
// for nothing and makes no database call. It fails when metricsRegistry
// already holds counters of the same names.
func (c *Cache) registerCounters(metricsRegistry prometheus.Registerer) error {
c.cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_cache_hits_total",
Help: "Image requests served from the cache.",
})
c.cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_cache_misses_total",
Help: "Image requests not served from the cache.",
})
for _, counter := range []prometheus.Counter{c.cacheHits, c.cacheMisses} {
err := metricsRegistry.Register(counter)
if err != nil {
return fmt.Errorf("failed to register the cache's counters: %w", err)
}
}
return nil
}
@@ -1,64 +0,0 @@
package imgcache
import (
"errors"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// TestNewCache_RegistersItsCountersOnItsRegistry checks that NewCache
// registers the cache's counters on the registry it is given, under the
// names README.md gives them.
func TestNewCache_RegistersItsCountersOnItsRegistry(t *testing.T) {
t.Parallel()
registry := prometheus.NewRegistry()
_, err := NewCache(evictionTestDB(t),
Config{StateDir: t.TempDir(), MaxBytes: 1 << 30}, registry)
if err != nil {
t.Fatalf("NewCache() error = %v", err)
}
names := []string{
"pixa_cache_hits_total",
"pixa_cache_misses_total",
}
count, err := testutil.GatherAndCount(registry, names...)
if err != nil {
t.Fatalf("testutil.GatherAndCount() error = %v", err)
}
if count != len(names) {
t.Errorf("%d of the counters %v are on the registry, want %d",
count, names, len(names))
}
}
// TestNewCache_FailsWhenItsCountersCannotBeRegistered makes a second cache
// on the registry of a first. Its counters have the same names, so the
// registry refuses them, and NewCache must return that error.
func TestNewCache_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
t.Parallel()
registry := prometheus.NewRegistry()
_, err := NewCache(evictionTestDB(t),
Config{StateDir: t.TempDir(), MaxBytes: 1 << 30}, registry)
if err != nil {
t.Fatalf("NewCache() of the first cache error = %v", err)
}
_, err = NewCache(evictionTestDB(t),
Config{StateDir: t.TempDir(), MaxBytes: 1 << 30}, registry)
t.Logf("NewCache() of the second cache error = %v", err)
var alreadyRegistered prometheus.AlreadyRegisteredError
if !errors.As(err, &alreadyRegistered) {
t.Errorf("NewCache() of the second cache error = %v, want %T",
err, alreadyRegistered)
}
}
@@ -1,4 +1,4 @@
package imageproxy
package imgcache
import (
"fmt"

Some files were not shown because too many files have changed in this diff Show More