Compare commits
16
Commits
2d570fba94
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87cb1ab5f6 | ||
|
|
77d55abb7e | ||
|
|
7b6712538e | ||
|
|
63690a9ec9 | ||
|
|
ac2bae0489 | ||
|
|
b49d0068df | ||
|
|
6b19e4c96c | ||
|
|
5f9d624d74 | ||
|
|
a19cca2f34 | ||
|
|
00d8ba26c5 | ||
|
|
385c0ca76f | ||
|
|
989da78681 | ||
|
|
4fb6350aa1 | ||
|
|
cde9c0d10d | ||
|
|
957ec85f97 | ||
|
|
930e43961e |
@@ -18,4 +18,3 @@ jobs:
|
|||||||
# All history and tags, so git describe finds the version tag.
|
# All history and tags, so git describe finds the version tag.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
- run: script/docker-smoke
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ 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
|
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
|
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
|
ask for permission to proceed, then stop. You need explicit user approval to
|
||||||
modify existing tests. (You do not need user approval for writing NEW tests.)
|
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.)
|
||||||
|
|
||||||
- When linting, assume the linter config is CORRECT, and that each item output
|
- 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.
|
by the linter is something that legitimately needs fixing in the code.
|
||||||
|
|||||||
@@ -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
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest bench
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
@@ -64,6 +64,12 @@ docker-smoke:
|
|||||||
loadtest:
|
loadtest:
|
||||||
@script/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)
|
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
||||||
docker-versioned:
|
docker-versioned:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ database and the disk cache:
|
|||||||
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
||||||
must be able to write the directory.
|
must be able to write the directory.
|
||||||
- `cache_max_bytes` limits the source and transformed images together. The
|
- `cache_max_bytes` limits the source and transformed images together. The
|
||||||
database, the metadata files, the `.meta` file beside each transformed image
|
database and files still being written come on top, and eviction runs in the
|
||||||
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
|
background, so the cache can pass the limit for a while: leave room on the
|
||||||
volume beyond it.
|
volume beyond it.
|
||||||
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
||||||
@@ -89,13 +88,17 @@ A load balancer's health check can request `/.well-known/healthcheck.json`,
|
|||||||
which answers 200 whenever pixa is running, in maintenance mode too (see
|
which answers 200 whenever pixa is running, in maintenance mode too (see
|
||||||
`maintenance_mode`).
|
`maintenance_mode`).
|
||||||
|
|
||||||
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
On SIGTERM or SIGINT pixa stops accepting connections. The requests in progress
|
||||||
progress and the images being processed 5 seconds to finish, then writes to the
|
and the images being processed get 5 seconds to finish. A request not finished
|
||||||
database the counts of cache hits, misses, fetches and conversions that requests
|
by then is cut off. pixa then writes the waiting last-used times of cached
|
||||||
could not write by their deadline, and exits: with 0, or with 1 when images were
|
images to the database (see Storage) and exits. It exits with 1 when:
|
||||||
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
|
- images were still being processed after those 5 seconds,
|
||||||
is cut off. `docker stop` waits 10 seconds before it kills the container.
|
- 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.
|
||||||
|
|
||||||
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
|
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
|
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
||||||
@@ -177,38 +180,46 @@ allowlisted source hosts for open access.
|
|||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
- **Source content**:
|
- **Source images**:
|
||||||
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
|
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of host, path and query>`, one
|
||||||
- **Source metadata**:
|
file for each source URL
|
||||||
`<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)
|
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
|
||||||
- **Transformed images**:
|
- **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
|
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
|
||||||
name.
|
name.
|
||||||
|
|
||||||
Multiple source paths may reference the same content blob; the database tracks
|
Each source image and each transformed image has one row in the database,
|
||||||
references rather than using filesystem refcounting.
|
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.
|
||||||
|
|
||||||
Source and transformed image files, and the `.meta` file beside each transformed
|
Source and transformed image files are written under a temporary name, synced to
|
||||||
image, are written under a temporary name, synced to disk, and only then renamed
|
disk, and only then renamed into place, so a crash leaves no file under its own
|
||||||
into place, so a crash leaves no file under its own name with part of its data.
|
name with part of its data. When pixa starts, and every five minutes after, it
|
||||||
When pixa starts, and every five minutes after, it removes each cached source or
|
removes each cached source or transformed image whose file is empty, or not the
|
||||||
transformed image whose file is empty, or not the size the database records for
|
size the database records for it, with a warning naming the file; the next
|
||||||
it, with a warning naming the file; the next request for it fetches the source
|
request for it fetches the source or converts the image again. Looking for those
|
||||||
or converts the image again. Looking for those files reads the database once for
|
files reads the database once for every cached file, so pixa skips each
|
||||||
every cached file, so pixa skips each five-minute look that would start less
|
five-minute look that would start less than ten times as long after the last
|
||||||
than ten times as long after the last look ended as that look took. After a look
|
look ended as that look took. After a look that took one minute, the next one
|
||||||
that took one minute, the next one starts 10 to 15 minutes after that look
|
starts 10 to 15 minutes after that look ended.
|
||||||
ended.
|
|
||||||
|
|
||||||
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||||
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
Test). Toward it, a cache hit opens the transformed image's file and nothing
|
||||||
transformed images most recently cached or served, so a cache hit on one of them
|
else, and makes no database call. The content type comes from the file's first
|
||||||
reads only the image file from disk and not the metadata file stored beside it.
|
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).
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -237,30 +248,58 @@ path under `/v1/` answers 200, in maintenance mode too.
|
|||||||
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
|
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
|
||||||
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
|
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
|
||||||
and converted (below). Needs: a signature, unless the host is allowlisted (see
|
and converted (below). Needs: a signature, unless the host is allowlisted (see
|
||||||
Source Hosts). Answers: 200; 304 when `If-None-Match` matches the image's
|
Source Hosts). Answers:
|
||||||
`ETag`; 400 for a URL or parameter that is not valid, or for the format `auto`
|
- 200 with the image.
|
||||||
an `Accept` header that is not valid; 406 for the format `auto` when `Accept`
|
- 304 when `If-None-Match` matches the image's `ETag`.
|
||||||
allows none of the formats it chooses from; 401 for a missing or wrong
|
- 400 for a URL or parameter that is not valid.
|
||||||
signature, a missing `exp` or an `exp` in the past; 403 when the request's
|
- 400 when the format is `auto` and the `Accept` header is not valid.
|
||||||
`Referer` names a host in `referer_blocklist`, checked before the signature,
|
- 406 when the format is `auto` and `Accept` allows none of the formats it
|
||||||
the cache and the upstream fetch; 403 when the upstream host, or a host it
|
chooses from.
|
||||||
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
- 401 for a missing or wrong signature.
|
||||||
address in a blocked network (see `blocked_networks`); 429 over
|
- 401 for a missing `exp`.
|
||||||
`image_requests_per_minute` (below); 415 when the upstream answers with a
|
- 401 for an `exp` in the past.
|
||||||
`Content-Type` that is not one of the source formats (below), or with an image
|
- 403 when the request's `Referer` names a host in `referer_blocklist`,
|
||||||
whose first bytes libvips reads as another format; 502 when the upstream
|
checked before the signature, the cache and the upstream fetch.
|
||||||
answered with an error status, and for 5 minutes after that for an image of
|
- 403 when the upstream host, or a host it redirects to, is `localhost` or
|
||||||
the same source URL that pixa can neither serve from its cache nor make from
|
ends in `.localhost` or `.local`.
|
||||||
the source image still in its cache; 503 when pixa is busy or in maintenance
|
- 403 when the upstream host, or a host it redirects to, has an address in a
|
||||||
mode; 500 for any other failure.
|
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.
|
||||||
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||||
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
(see Encrypted URLs). Needs: nothing but the URL. Answers:
|
||||||
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
- 200 with the image.
|
||||||
decrypt, or that asks for a size or fit that is not valid; 410 once it has
|
- 304 when `If-None-Match` matches the image's `ETag`.
|
||||||
expired; 504 when the upstream has not sent its response headers within
|
- 400 for a token that does not decrypt.
|
||||||
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
- 400 for a token that asks for a size or fit that is not valid.
|
||||||
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
- 410 once the token has expired.
|
||||||
406, 403, 415, 429, 502, 503 and 500, as for `/v1/image/`.
|
- 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
|
||||||
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
||||||
nothing. Answers: 200.
|
nothing. Answers: 200.
|
||||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||||
@@ -355,11 +394,22 @@ 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
|
cover the size and so past it on one side, when that side would come out
|
||||||
above 8192.
|
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
|
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.
|
`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
|
An upstream answering with any other `Content-Type`, SVG included, is refused
|
||||||
with 415, and so is an image whose first bytes libvips reads as another format,
|
with 415. So is a body whose first bytes are not those of the format its
|
||||||
such as an SVG whose first bytes pass the check for AVIF.
|
`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 the format `auto`, pixa chooses the format for each request from its
|
With the format `auto`, pixa chooses the format for each request from its
|
||||||
`Accept` header, in this order:
|
`Accept` header, in this order:
|
||||||
@@ -404,14 +454,18 @@ 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
|
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
|
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
|
`downstream_timeout`. The limit of 8192 on each side of the source and of the
|
||||||
output bounds how long that takes. With one libvips thread, as pixad runs it,
|
output, and for AVIF the limit of 16,777,216 pixels, bound how long that takes.
|
||||||
encoding 8192x8192 random pixels takes up to about 1 second as JPEG, 15 as PNG,
|
With one libvips thread, as pixad runs it, encoding 8192x8192 random pixels
|
||||||
16 as GIF, 63 as WebP, 110 as AVIF and 96 as JPEG XL, the slowest of 8 or 16
|
takes up to about 1 second as JPEG, 15 as PNG and 16 as GIF, the slowest of 8 or
|
||||||
bits per sample, with or without an alpha channel. WebP, AVIF and JPEG XL are
|
16 bits per sample, with or without an alpha channel. The WebP and JPEG XL
|
||||||
that slow only with an alpha channel: 8-bit RGB takes about 44, 53 and 28
|
encoders run below libvips' default effort, and AVIF at the lowest effort govips
|
||||||
seconds. Decoding such a source adds up to about 11 seconds. A WebP, AVIF or
|
can set, so that no encode takes more than 40 seconds of the default
|
||||||
JPEG XL encode can therefore hold its slot past the default `downstream_timeout`
|
`downstream_timeout` of 60: at any quality and 8 or 16 bits per sample,
|
||||||
of 60 seconds.
|
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.
|
||||||
|
|
||||||
The login form (`POST /`) is limited to 5 attempts per minute per client
|
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
|
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||||
@@ -463,13 +517,13 @@ nor change what it asks for.
|
|||||||
sent with an empty format, or none, also makes a JPEG XL URL. Width and
|
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
|
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
|
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 a source URL
|
scaled side would come out above 8192 answers 400, and so does an AVIF URL of
|
||||||
whose path holds a `..` segment. So does a source URL that the image URL
|
more than 16,777,216 pixels or a source URL whose path holds a `..` segment.
|
||||||
would fetch written another way, and the page then shows the URL pixa would
|
So does a source URL that the image URL would fetch written another way, and
|
||||||
fetch: one with a user name and password, which pixa does not send, or one
|
the page then shows the URL pixa would fetch: one with a user name and
|
||||||
whose path holds `%2F`, fetched as `/`, or `(`, fetched as `%28`, as pixa
|
password, which pixa does not send, or one whose path holds `%2F`, fetched as
|
||||||
keeps the source path decoded and fetches it percent-encoded again (see
|
`/`, or `(`, fetched as `%28`, as pixa keeps the source path decoded and
|
||||||
Routes).
|
fetches it percent-encoded again (see Routes).
|
||||||
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
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
|
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
|
with `http` instead while `debug` is on. The name after the token is ignored
|
||||||
@@ -713,16 +767,22 @@ Key settings in more detail:
|
|||||||
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
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
|
`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
|
private network; an explicitly empty list (`[]`) trusts no one, and an
|
||||||
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
explicit list replaces the default. An invalid CIDR aborts startup. An IPv6
|
||||||
to the address pixa sees for requests that come through your proxy, such as
|
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
|
||||||
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
address it holds, so an IPv6 range such as `::/0` trusts neither it nor a
|
||||||
(see the login limit and `image_requests_per_minute` under Routes). For a
|
plain IPv4 address. An entry written in that form is read as the IPv4 range it
|
||||||
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
|
covers, so `::ffff:10.0.0.0/104` trusts what `10.0.0.0/8` trusts; such an
|
||||||
is the gateway of the container's Docker network (`172.17.0.1` on the default
|
entry shorter than `/96` covers more than IPv4 addresses and aborts startup.
|
||||||
bridge), not the proxy's own address; a proxy that connects through another of
|
Set this to the address pixa sees for requests that come through your proxy,
|
||||||
the host's addresses is seen with that address. To be sure which address it
|
such as `172.17.0.1/32`, when the defaults do not cover it, or to trust
|
||||||
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
nothing else (see the login limit and `image_requests_per_minute` under
|
||||||
through the proxy, and read `remoteIP` in pixa's request log line for it
|
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,
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||||
as a duration such as `30s` (the default) or `2m`
|
as a duration such as `30s` (the default) or `2m`
|
||||||
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||||
@@ -731,7 +791,17 @@ Key settings in more detail:
|
|||||||
duration; default `60s`. The upstream fetch counts toward it, and so do the
|
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
|
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
|
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
||||||
- `signing_key` — HMAC secret for URL signatures
|
- `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
|
||||||
|
|
||||||
- `db_url` — the SQLite database to open; omitted, it is
|
- `db_url` — the SQLite database to open; omitted, it is
|
||||||
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
`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
|
database in WAL mode. `state_dir` is percent-encoded in it, so a `%`, `?` or
|
||||||
@@ -778,20 +848,34 @@ See `configs/config.example.yml` for all options with defaults.
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
- **Dependency injection**: Uber fx
|
- **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`.
|
||||||
- **HTTP router**: go-chi
|
- **HTTP router**: go-chi
|
||||||
- **Image processing**: govips (CGO wrapper for libvips)
|
- **Image processing**: govips (CGO wrapper for libvips)
|
||||||
- **Database**: SQLite via modernc.org/sqlite
|
- **Database**: SQLite via modernc.org/sqlite
|
||||||
- **Static assets**: embedded via `//go:embed`
|
- **Static assets**: embedded via `//go:embed`
|
||||||
- **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics
|
- **Metrics**: Prometheus, at `/metrics`. pixa serves `/metrics` and measures
|
||||||
(duration, response size, requests in flight) and the Go runtime and process
|
HTTP requests only when `metrics.username` and `metrics.password` are set.
|
||||||
metrics; requests are measured and `/metrics` is served only when
|
`/metrics` holds:
|
||||||
`metrics.username` and `metrics.password` are set. A request's `handler` label
|
- the duration of HTTP requests
|
||||||
is the pattern of the route it matches, such as `/v1/image/*`, never its path.
|
- the size of HTTP responses
|
||||||
Requests that match no route share the label `unmatched`: those the router
|
- the HTTP requests in flight
|
||||||
answers 404 or 405, and CORS preflights. A request's `method` label is its
|
- the Go runtime and process metrics
|
||||||
method when that is `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`,
|
- `pixa_cache_hits_total`: image requests served from the cache
|
||||||
`OPTIONS`, `CONNECT` or `TRACE`, and `other` for any other method
|
- `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.
|
||||||
|
|
||||||
- **Logging**: stdlib slog
|
- **Logging**: stdlib slog
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
@@ -823,6 +907,9 @@ provide:
|
|||||||
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
- `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
|
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a
|
||||||
benchmark, not part of `script/check` (see Load Test)
|
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`),
|
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`),
|
||||||
then `script/check`, then build the image as `script/docker` does
|
then `script/check`, then build the image as `script/docker` does
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
@@ -840,15 +927,16 @@ 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
|
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
|
`-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,
|
golangci-lint's cache, which reuses a package's findings only when its files,
|
||||||
those of the packages it imports, `go.mod`, `.golangci.yml` and the
|
those of the packages it imports, `go.mod`, the linters and their settings in
|
||||||
golangci-lint version are all unchanged. The `test` phase runs on the Debian Go
|
`.golangci.yml`, and the golangci-lint version are all unchanged. The `test`
|
||||||
image, with Debian's libvips and libheif; the image pixa ships is built and runs
|
phase runs on the Debian Go image, with Debian's libvips and libheif; the image
|
||||||
on Alpine. `script/check`, `script/cibuild`, `script/docker`, `script/lint`,
|
pixa ships is built and runs on Alpine. `script/check`, `script/cibuild`,
|
||||||
`script/test`, `script/setup` and `script/install-precommit` are the standard
|
`script/docker`, `script/lint`, `script/test`, `script/setup` and
|
||||||
copies from `sneak/prompts`, kept identical to them. `script/fmt` and
|
`script/install-precommit` are the standard copies from `sneak/prompts`, kept
|
||||||
`script/fmt-check` are the standard copies with pixa's `gofmt` step kept before
|
identical to them. `script/fmt` and `script/fmt-check` are the standard copies
|
||||||
prettier. prettier formats the markdown only, not the HTML templates, as it
|
with pixa's `gofmt` step kept before prettier. prettier formats the markdown
|
||||||
cannot parse a Go template action inside a tag.
|
only, not the HTML templates, as it cannot parse a Go template action inside a
|
||||||
|
tag.
|
||||||
|
|
||||||
## Load Test
|
## Load Test
|
||||||
|
|
||||||
@@ -902,6 +990,17 @@ 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
|
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
|
||||||
nothing about the target.
|
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
|
## TODO
|
||||||
|
|
||||||
See [TODO.md](TODO.md) for the full prioritized task list.
|
See [TODO.md](TODO.md) for the full prioritized task list.
|
||||||
|
|||||||
@@ -30,10 +30,202 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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):
|
- 2026-10-18 re-vendored from `sneak/prompts` commit `0653579` (closes #221):
|
||||||
each `Dockerfile` stage installs its system packages and downloads the Go
|
each `Dockerfile` stage installs its system packages and downloads the Go
|
||||||
modules above `ARG CACHEBUST`, so check builds keep them, and the lint step
|
modules above `ARG CACHEBUST`, so check builds keep them, and the lint step
|
||||||
keeps golangci-lint's cache.
|
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
|
- 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
|
the last one took, so passes on a large cache no longer run back to back
|
||||||
(closes #250).
|
(closes #250).
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ require (
|
|||||||
github.com/go-chi/httprate v0.16.0
|
github.com/go-chi/httprate v0.16.0
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
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/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ 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.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 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
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 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
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=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
|||||||
@@ -72,13 +72,3 @@ func (w *HostAllowList) IsAllowed(u *url.URL) bool {
|
|||||||
|
|
||||||
return false
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -139,87 +139,3 @@ 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/middleware"
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/providers"
|
||||||
"sneak.berlin/go/pixa/internal/server"
|
"sneak.berlin/go/pixa/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,7 +63,10 @@ func run(_ *cobra.Command, _ []string) {
|
|||||||
server.New,
|
server.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
// The registry /metrics serves
|
||||||
|
func() prometheus.Registerer { return prometheus.DefaultRegisterer },
|
||||||
),
|
),
|
||||||
|
fx.Provide(providers.Constructors()...),
|
||||||
fx.Invoke(
|
fx.Invoke(
|
||||||
func(log *logger.Logger) { log.Identify() },
|
func(log *logger.Logger) { log.Identify() },
|
||||||
func(*server.Server) {},
|
func(*server.Server) {},
|
||||||
|
|||||||
@@ -135,10 +135,38 @@ 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
|
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
||||||
// cache_max_bytes is recorded as not explicit, so the cache works out
|
// cache_max_bytes is recorded as not explicit, so pixad works out the
|
||||||
// the default when it opens, and that an explicit zero is recorded as
|
// default when it builds the cache, and that an explicit zero is recorded
|
||||||
// explicit, so it disables the disk cache instead.
|
// as explicit, so it disables the disk cache instead.
|
||||||
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+58
-43
@@ -45,6 +45,37 @@ func DefaultMaxConcurrentProcessing() int {
|
|||||||
return max(runtime.GOMAXPROCS(0)/2, 1) //nolint:mnd // half the CPUs
|
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.
|
// Configuration key names.
|
||||||
const (
|
const (
|
||||||
keyDebug = "debug"
|
keyDebug = "debug"
|
||||||
@@ -190,18 +221,14 @@ type Config struct {
|
|||||||
// address, and an explicit list replaces the default.
|
// address, and an explicit list replaces the default.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// CacheMaxBytes is the disk cache size limit in bytes. Only an
|
// CacheMaxBytes is the disk cache size limit in bytes, as set; 0
|
||||||
// explicit zero (CacheMaxBytesExplicit true) disables the disk
|
// turns the disk cache off.
|
||||||
// cache. Zero with CacheMaxBytesExplicit false means
|
|
||||||
// cache_max_bytes was omitted, and the cache works out the default
|
|
||||||
// limit when it opens.
|
|
||||||
CacheMaxBytes int64
|
CacheMaxBytes int64
|
||||||
|
|
||||||
// CacheMaxBytesExplicit records whether cache_max_bytes was
|
// CacheMaxBytesExplicit records whether cache_max_bytes was
|
||||||
// explicitly set, in the environment or the configuration file.
|
// explicitly set, in the environment or the configuration file.
|
||||||
// Explicit values are used exactly as given; for an omitted key the
|
// Explicit values are used exactly as given. For an omitted key
|
||||||
// cache works out the default limit when it opens (see
|
// pixad works out DefaultCacheMaxBytes when it builds the cache.
|
||||||
// imgcache.CacheConfig.UseDefaultMaxBytes).
|
|
||||||
CacheMaxBytesExplicit bool
|
CacheMaxBytesExplicit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +266,8 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// An omitted cache_max_bytes is worked out and logged when the
|
// An omitted cache_max_bytes is worked out and logged when pixad
|
||||||
// cache opens.
|
// builds the cache.
|
||||||
if c.CacheMaxBytesExplicit {
|
if c.CacheMaxBytesExplicit {
|
||||||
log.Info("effective cache size limit",
|
log.Info("effective cache size limit",
|
||||||
"cache_max_bytes", c.CacheMaxBytes,
|
"cache_max_bytes", c.CacheMaxBytes,
|
||||||
@@ -272,7 +299,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
blockedNetworks, err := parseBlockedNetworks(sc)
|
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -325,8 +352,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The default for an omitted cache_max_bytes is worked out when
|
// The default for an omitted cache_max_bytes is worked out when
|
||||||
// the cache opens; here we only record whether the operator set
|
// pixad builds the cache; here we only record whether the operator
|
||||||
// the key explicitly.
|
// set the key explicitly.
|
||||||
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
|
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
@@ -1249,6 +1276,13 @@ func defaultTrustedProxies() []netip.Prefix {
|
|||||||
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
||||||
// aborts startup naming the key and the offending value; the default
|
// aborts startup naming the key and the offending value; the default
|
||||||
// (an empty list) applies only to an omitted key.
|
// (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) {
|
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||||
raw, ok := lookupValue(sc, key)
|
raw, ok := lookupValue(sc, key)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1264,6 +1298,10 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return nil, err
|
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))
|
prefixes := make([]netip.Prefix, 0, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -1273,42 +1311,19 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
settingName(key), entry, errNotAValidCIDR)
|
settingName(key), entry, errNotAValidCIDR)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefixes = append(prefixes, prefix)
|
if prefix.Addr().Is4In6() && prefix.Bits() < mappedPrefixBits {
|
||||||
}
|
|
||||||
|
|
||||||
return prefixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseBlockedNetworks parses blocked_networks with parseCIDRList, and keeps
|
|
||||||
// an entry written in the IPv4-mapped form (::ffff:a.b.c.d) as the IPv4
|
|
||||||
// range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. The fetcher
|
|
||||||
// matches 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 parseBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
|
||||||
prefixes, err := parseCIDRList(sc, keyBlockedNetworks)
|
|
||||||
if err != nil {
|
|
||||||
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
|
|
||||||
|
|
||||||
for i, prefix := range prefixes {
|
|
||||||
if !prefix.Addr().Is4In6() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if prefix.Bits() < mappedPrefixBits {
|
|
||||||
return nil, fmt.Errorf("%s: value %q is %w",
|
return nil, fmt.Errorf("%s: value %q is %w",
|
||||||
settingName(keyBlockedNetworks), prefix, errMappedTooShort)
|
settingName(key), entry, errMappedTooShort)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefixes[i] = netip.PrefixFrom(prefix.Addr().Unmap(),
|
if prefix.Addr().Is4In6() {
|
||||||
|
prefix = netip.PrefixFrom(prefix.Addr().Unmap(),
|
||||||
prefix.Bits()-mappedPrefixBits)
|
prefix.Bits()-mappedPrefixBits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
return prefixes, nil
|
return prefixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,3 +83,41 @@ 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,40 +104,31 @@ func writeConcurrently(t *testing.T, db *sql.DB) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
var hits, sources int
|
var sources int
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM source_content`).
|
||||||
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
|
Scan(&sources)
|
||||||
FROM cache_stats WHERE id = 1
|
|
||||||
`).Scan(&hits, &sources)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to count the writes: %v", err)
|
t.Fatalf("failed to count the writes: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hits != totalRequests || sources != totalRequests {
|
if sources != totalRequests {
|
||||||
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
|
t.Errorf("%d source_content rows, want %d", sources, totalRequests)
|
||||||
hits, sources, totalRequests)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLikeOneRequest makes the writes one request and the eviction pass
|
// writeLikeOneRequest makes the writes one request and the eviction pass
|
||||||
// make: it counts a cache hit, stores a source, records a transformed image
|
// make: it stores a source, records a transformed image and deletes that
|
||||||
// and deletes that record again.
|
// record again.
|
||||||
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
|
(source_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("storing a source: %w", err)
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
||||||
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
|
(cache_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("recording a transformed image: %w", err)
|
return fmt.Errorf("recording a transformed image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ func ParseMigrationVersion(filename string) (int, error) {
|
|||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Database instance.
|
// 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.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||||
s := &Database{
|
s := &Database{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
@@ -91,20 +95,16 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
|
|
||||||
s.log.Info("Database instantiated")
|
s.log.Info("Database instantiated")
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
err := s.connect(context.Background())
|
||||||
OnStart: func(ctx context.Context) error {
|
if err != nil {
|
||||||
s.log.Info("Database OnStart Hook")
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return s.connect(ctx)
|
lc.Append(fx.Hook{
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
s.log.Info("Database OnStop Hook")
|
s.log.Info("Database OnStop Hook")
|
||||||
|
|
||||||
if s.db != nil {
|
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the application tables created by 001.sql exist.
|
// Verify that the application tables created by 001.sql exist.
|
||||||
tables := []string{
|
tables := []string{"source_content", "negative_cache"}
|
||||||
"source_content", "source_metadata", "output_content",
|
|
||||||
"request_cache", "negative_cache", "cache_stats",
|
|
||||||
}
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,27 @@
|
|||||||
-- Migration 001: Initial schema
|
-- Migration 001: Initial schema
|
||||||
-- Creates all tables for the pixa caching image proxy
|
-- Creates all tables for the pixa caching image proxy
|
||||||
|
|
||||||
-- Source content blobs
|
-- Source images, one row per source URL
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
||||||
|
-- is the SHA-256 of the source's host, path and query
|
||||||
CREATE TABLE IF NOT EXISTS source_content (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
ON source_content(last_accessed_at);
|
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
|
-- Processed variant blobs
|
||||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key>. Tracked here
|
||||||
-- sidecar with the content type). Tracked here (like source content
|
-- (like source content blobs above) so total cache usage is known
|
||||||
-- blobs above) so total cache usage is known without a directory scan,
|
-- without a directory scan, and so LRU eviction has a timestamp to order
|
||||||
-- and so LRU eviction has a timestamp to order on.
|
-- 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.
|
||||||
CREATE TABLE IF NOT EXISTS variant_content (
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
cache_key TEXT PRIMARY KEY,
|
cache_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
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
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
@@ -120,63 +95,19 @@ BEGIN
|
|||||||
WHERE id = 1;
|
WHERE id = 1;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
-- Output/transformed content blobs
|
-- Negative cache: source URLs whose fetch failed with an upstream error
|
||||||
-- Not written: transformed images are stored in cache/variants and
|
-- status, or whose first bytes did not match their Content-Type. pixa does
|
||||||
-- tracked in variant_content above.
|
-- not fetch such a URL again until its row expires.
|
||||||
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 (
|
CREATE TABLE IF NOT EXISTS negative_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
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,
|
expires_at DATETIME NOT NULL,
|
||||||
UNIQUE(source_host, source_path, source_query)
|
UNIQUE(source_host, source_path, source_query)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_negative_cache_expires ON negative_cache(expires_at);
|
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
|
-- 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
|
-- 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.
|
-- carrying another value is refused, so logging out ends every login session.
|
||||||
|
|||||||
+50
-70
@@ -7,18 +7,15 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values for optional fields.
|
// Default values for optional fields.
|
||||||
const (
|
const (
|
||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imgcache.FormatJXL
|
DefaultFormat = imagerequest.FormatJXL
|
||||||
DefaultFitMode = imgcache.FitCover
|
DefaultFitMode = imagerequest.FitCover
|
||||||
|
|
||||||
// HKDF salt for URL encryption key derivation
|
|
||||||
urlKeySalt = "pixa-urlenc-v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Errors returned by encurl operations.
|
// Errors returned by encurl operations.
|
||||||
@@ -28,18 +25,18 @@ var (
|
|||||||
ErrDecryptFailed = errors.New("failed to decrypt URL")
|
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.
|
// Field names are short to minimize encoded size.
|
||||||
// Fields with omitempty use sane defaults when absent.
|
// Fields with omitempty use sane defaults when absent.
|
||||||
type Payload struct {
|
type payload struct {
|
||||||
SourceHost string `cbor:"h"` // required
|
SourceHost string `cbor:"h"` // required
|
||||||
SourcePath string `cbor:"p"` // required
|
SourcePath string `cbor:"p"` // required
|
||||||
SourceQuery string `cbor:"q,omitempty"` // optional
|
SourceQuery string `cbor:"q,omitempty"` // optional
|
||||||
Width int `cbor:"w,omitempty"` // 0 = original
|
Width int `cbor:"w,omitempty"` // 0 = original
|
||||||
Height int `cbor:"ht,omitempty"` // 0 = original
|
Height int `cbor:"ht,omitempty"` // 0 = original
|
||||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
|
Format imagerequest.Format `cbor:"f,omitempty"` // default: jxl
|
||||||
Quality int `cbor:"ql,omitempty"` // default: 85
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
||||||
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
FitMode imagerequest.Fit `cbor:"fm,omitempty"` // default: cover
|
||||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,20 +45,35 @@ type Generator struct {
|
|||||||
key [seal.KeySize]byte
|
key [seal.KeySize]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGenerator creates an encrypted URL generator with a key derived
|
// NewGenerator creates an encrypted URL generator that encrypts its tokens
|
||||||
// from the signing key.
|
// with key.
|
||||||
func NewGenerator(signingKey string) (*Generator, error) {
|
func NewGenerator(key [seal.KeySize]byte) *Generator {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
return &Generator{key: key}
|
||||||
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
|
// CBOR encode the payload
|
||||||
data, err := cbor.Marshal(p)
|
data, err := cbor.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,40 +84,35 @@ func (g *Generator) Generate(p *Payload) (string, error) {
|
|||||||
return seal.Encrypt(g.key, data)
|
return seal.Encrypt(g.key, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse decrypts and validates an encrypted URL token.
|
// 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.
|
||||||
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
||||||
func (g *Generator) Parse(token string) (*Payload, error) {
|
func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, error) {
|
||||||
// Decrypt
|
// Decrypt
|
||||||
data, err := seal.Decrypt(g.key, token)
|
data, err := seal.Decrypt(g.key, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
||||||
errors.Is(err, seal.ErrInvalidPayload) {
|
errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
return nil, ErrDecryptFailed
|
return nil, time.Time{}, ErrDecryptFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
var p Payload
|
var p payload
|
||||||
|
|
||||||
err = cbor.Unmarshal(data, &p)
|
err = cbor.Unmarshal(data, &p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidFormat
|
return nil, time.Time{}, ErrInvalidFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check expiration (0 = never expires)
|
// Check expiration (0 = never expires)
|
||||||
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
|
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
|
||||||
return nil, ErrExpired
|
return nil, time.Time{}, 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
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
format = DefaultFormat
|
format = DefaultFormat
|
||||||
@@ -126,44 +133,17 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
expires = time.Unix(p.ExpiresAt, 0)
|
expires = time.Unix(p.ExpiresAt, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imagerequest.Request{
|
||||||
SourceHost: p.SourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: p.SourcePath,
|
Host: p.SourceHost,
|
||||||
SourceQuery: p.SourceQuery,
|
Path: p.SourcePath,
|
||||||
Size: imgcache.Size{
|
Query: p.SourceQuery,
|
||||||
Width: p.Width,
|
|
||||||
Height: p.Height,
|
|
||||||
},
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: p.Width, Height: p.Height},
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
Expires: expires,
|
},
|
||||||
}
|
}, expires, nil
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-186
@@ -6,7 +6,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared test fixture strings.
|
// Shared test fixture strings.
|
||||||
@@ -19,24 +20,29 @@ const (
|
|||||||
func TestGenerator_GenerateAndParse(t *testing.T) {
|
func TestGenerator_GenerateAndParse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, err := encurl.NewGenerator("test-signing-key-12345")
|
keys, err := seal.DeriveKeys("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
req := &imagerequest.Request{
|
||||||
SourceQuery: testSourceQuery,
|
Source: imagerequest.Source{
|
||||||
Width: 800,
|
Host: testSourceHost,
|
||||||
Height: 600,
|
Path: testSourcePath,
|
||||||
Format: imgcache.FormatWebP,
|
Query: testSourceQuery,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
|
Format: imagerequest.FormatWebP,
|
||||||
Quality: 90,
|
Quality: 90,
|
||||||
FitMode: imgcache.FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
},
|
||||||
}
|
}
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
token, err := gen.Generate(payload)
|
token, err := gen.Generate(req, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -45,66 +51,73 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
t.Fatal("Generate() returned empty token")
|
t.Fatal("Generate() returned empty token")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := gen.Parse(token)
|
parsed, parsedExpires, err := gen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse() error = %v", err)
|
t.Fatalf("Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all fields
|
// Verify all fields
|
||||||
if parsed.SourceHost != payload.SourceHost {
|
if parsed.Source.Host != req.Source.Host {
|
||||||
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", parsed.Source.Host, req.Source.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourcePath != payload.SourcePath {
|
if parsed.Source.Path != req.Source.Path {
|
||||||
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", parsed.Source.Path, req.Source.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourceQuery != payload.SourceQuery {
|
if parsed.Source.Query != req.Source.Query {
|
||||||
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", parsed.Source.Query, req.Source.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Width != payload.Width {
|
if parsed.Transform.Size.Width != req.Transform.Size.Width {
|
||||||
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
t.Errorf("Width = %d, want %d",
|
||||||
|
parsed.Transform.Size.Width, req.Transform.Size.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Height != payload.Height {
|
if parsed.Transform.Size.Height != req.Transform.Size.Height {
|
||||||
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
t.Errorf("Height = %d, want %d",
|
||||||
|
parsed.Transform.Size.Height, req.Transform.Size.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Format != payload.Format {
|
if parsed.Transform.Format != req.Transform.Format {
|
||||||
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
t.Errorf("Format = %q, want %q",
|
||||||
|
parsed.Transform.Format, req.Transform.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Quality != payload.Quality {
|
if parsed.Transform.Quality != req.Transform.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d",
|
||||||
|
parsed.Transform.Quality, req.Transform.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.FitMode != payload.FitMode {
|
if parsed.Transform.FitMode != req.Transform.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q",
|
||||||
|
parsed.Transform.FitMode, req.Transform.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.ExpiresAt != payload.ExpiresAt {
|
if parsedExpires.Unix() != expires.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
t.Errorf("ExpiresAt = %d, want %d", parsedExpires.Unix(), expires.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_Expired(t *testing.T) {
|
func TestGenerator_Parse_Expired(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := gen.Generate(payload)
|
token, err := gen.Generate(req, time.Now().Add(-time.Hour)) // Already expired
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = gen.Parse(token)
|
_, _, err = gen.Parse(token)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail for expired token")
|
t.Error("Parse() should fail for expired token")
|
||||||
}
|
}
|
||||||
@@ -117,9 +130,10 @@ func TestGenerator_Parse_Expired(t *testing.T) {
|
|||||||
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
_, err := gen.Parse("not-a-valid-token")
|
_, _, err := gen.Parse("not-a-valid-token")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail for invalid token")
|
t.Error("Parse() should fail for invalid token")
|
||||||
}
|
}
|
||||||
@@ -128,15 +142,17 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
|||||||
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(payload)
|
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// Tamper with the token
|
// Tamper with the token
|
||||||
tampered := []byte(token)
|
tampered := []byte(token)
|
||||||
@@ -144,7 +160,7 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
tampered[10] ^= 0x01
|
tampered[10] ^= 0x01
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := gen.Parse(string(tampered))
|
_, _, err := gen.Parse(string(tampered))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail for tampered token")
|
t.Error("Parse() should fail for tampered token")
|
||||||
}
|
}
|
||||||
@@ -153,172 +169,78 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen1, _ := encurl.NewGenerator("signing-key-1")
|
keys1, _ := seal.DeriveKeys("signing-key-1")
|
||||||
gen2, _ := encurl.NewGenerator("signing-key-2")
|
keys2, _ := seal.DeriveKeys("signing-key-2")
|
||||||
|
gen1 := encurl.NewGenerator(keys1.EncryptedURLKey)
|
||||||
|
gen2 := encurl.NewGenerator(keys2.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen1.Generate(payload)
|
token, _ := gen1.Generate(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
_, err := gen2.Parse(token)
|
_, _, err := gen2.Parse(token)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail with different signing key")
|
t.Error("Parse() should fail with different signing key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest(t *testing.T) {
|
func TestGenerator_Parse_Defaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
SourceHost: testSourceHost,
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
SourcePath: testSourcePath,
|
|
||||||
SourceQuery: testSourceQuery,
|
// Request with only required fields - should get defaults
|
||||||
Width: 800,
|
token, err := gen.Generate(&imagerequest.Request{
|
||||||
Height: 600,
|
Source: imagerequest.Source{
|
||||||
Format: imgcache.FormatWebP,
|
Host: testSourceHost,
|
||||||
Quality: 90,
|
Path: testSourcePath,
|
||||||
FitMode: imgcache.FitContain,
|
},
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
}, time.Now().Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
req, _, err := gen.Parse(token)
|
||||||
|
if err != nil {
|
||||||
if req.SourceHost != payload.SourceHost {
|
t.Fatalf("Parse() error = %v", err)
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != payload.SourcePath {
|
if req.Transform.Format != encurl.DefaultFormat {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
t.Errorf("Format = %q, want default %q",
|
||||||
|
req.Transform.Format, encurl.DefaultFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != payload.SourceQuery {
|
if req.Transform.Quality != encurl.DefaultQuality {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
t.Errorf("Quality = %d, want default %d",
|
||||||
|
req.Transform.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Width != payload.Width {
|
if req.Transform.FitMode != encurl.DefaultFitMode {
|
||||||
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
t.Errorf("FitMode = %q, want default %q",
|
||||||
}
|
req.Transform.FitMode, encurl.DefaultFitMode)
|
||||||
|
|
||||||
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) {
|
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(payload)
|
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// Check that token only contains URL-safe characters
|
// Check that token only contains URL-safe characters
|
||||||
for _, c := range token {
|
for _, c := range token {
|
||||||
|
|||||||
+36
-41
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/clientip"
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
req, expiresAt, ttl, err := buildGenerateRequest(parsed, r.Form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@@ -153,21 +153,20 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The image URL must fetch the source URL as it was written
|
// The image URL must fetch the source URL as it was written. The
|
||||||
sourceRequest := payload.ToImageRequest()
|
// fetcher fetches over http only when allow_http is set.
|
||||||
sourceRequest.AllowHTTP = s.config.AllowHTTP
|
fetchURL := req.Source.URL(s.config.AllowHTTP)
|
||||||
fetchURL := sourceRequest.SourceURL()
|
|
||||||
|
|
||||||
if !fetchedAsWritten(parsed, fetchURL) {
|
if !fetchedAsWritten(parsed, fetchURL) {
|
||||||
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
||||||
"written: pixa would fetch "+fetchURL,
|
"written: pixa would fetch "+fetchURL.String(),
|
||||||
r.Form, http.StatusBadRequest)
|
r.Form, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(req, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
@@ -227,27 +226,26 @@ func parseSourceURL(sourceURL string) (*url.URL, error) {
|
|||||||
// in RawPath when it is not the decoded path percent-encoded again.
|
// in RawPath when it is not the decoded path percent-encoded again.
|
||||||
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
|
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
|
||||||
// such as a space, so RawPath is compared itself.
|
// such as a space, so RawPath is compared itself.
|
||||||
func fetchedAsWritten(source *url.URL, fetchURL string) bool {
|
func fetchedAsWritten(source, fetchURL *url.URL) bool {
|
||||||
writtenPath := source.RawPath
|
writtenPath := source.RawPath
|
||||||
if writtenPath == "" {
|
if writtenPath == "" {
|
||||||
writtenPath = source.EscapedPath()
|
writtenPath = source.EscapedPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched, err := url.Parse(fetchURL)
|
return source.User == nil && !source.ForceQuery &&
|
||||||
|
fetchURL.EscapedPath() == writtenPath &&
|
||||||
return err == nil && source.User == nil && !source.ForceQuery &&
|
fetchURL.RawQuery == source.RawQuery
|
||||||
fetched.EscapedPath() == writtenPath &&
|
|
||||||
fetched.RawQuery == source.RawQuery
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
// buildGenerateRequest parses the numeric form fields and assembles the
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
// image request the encrypted URL's token is made from, and its expiry.
|
||||||
// non-numeric or out-of-range width, height, quality or ttl, or an
|
// ttl=0 means never expires (the expiry is the zero time). A non-numeric or
|
||||||
// unrecognized fit mode, is a client error naming the offending field. The
|
// out-of-range width, height, quality or ttl, or an unrecognized fit mode, is
|
||||||
// format field is passed through unchecked.
|
// a client error naming the offending field. The format field is passed
|
||||||
func buildGeneratePayload(
|
// through unchecked.
|
||||||
|
func buildGenerateRequest(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*encurl.Payload, time.Time, int, error) {
|
) (*imagerequest.Request, time.Time, int, error) {
|
||||||
width, err := parseFormDimension(form, "width")
|
width, err := parseFormDimension(form, "width")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, 0, err
|
return nil, time.Time{}, 0, err
|
||||||
@@ -269,42 +267,39 @@ func buildGeneratePayload(
|
|||||||
return nil, time.Time{}, 0, err
|
return nil, time.Time{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fitMode := imgcache.FitMode(form.Get("fit"))
|
fitMode := imagerequest.Fit(form.Get("fit"))
|
||||||
|
|
||||||
err = imgcache.ValidateFitMode(fitMode)
|
err = imagerequest.ValidateFitMode(fitMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, 0,
|
return nil, time.Time{}, 0,
|
||||||
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, form.Get("fit"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var expiresAt time.Time
|
||||||
expiresAt time.Time
|
|
||||||
expiresAtUnix int64
|
|
||||||
)
|
|
||||||
|
|
||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
expiresAtUnix = expiresAt.Unix()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: parsed.Host,
|
Source: imagerequest.Source{
|
||||||
SourcePath: parsed.Path,
|
Host: parsed.Host,
|
||||||
SourceQuery: parsed.RawQuery,
|
Path: parsed.Path,
|
||||||
Width: width,
|
Query: parsed.RawQuery,
|
||||||
Height: height,
|
},
|
||||||
Format: imgcache.ImageFormat(form.Get("format")),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: width, Height: height},
|
||||||
|
Format: imagerequest.Format(form.Get("format")),
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
ExpiresAt: expiresAtUnix,
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, expiresAt, ttl, nil
|
return req, expiresAt, ttl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFormDimension reads an optional width or height form field. An empty
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
// value means "original size" (0). A non-numeric value, or one
|
// value means "original size" (0). A non-numeric value, or one
|
||||||
// imgcache.ValidateDimension rejects, is an error naming the field.
|
// imagerequest.ValidateDimension rejects, is an error naming the field.
|
||||||
func parseFormDimension(form url.Values, field string) (int, error) {
|
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||||
raw := form.Get(field)
|
raw := form.Get(field)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -316,7 +311,7 @@ func parseFormDimension(form url.Values, field string) (int, error) {
|
|||||||
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = imgcache.ValidateDimension(field, value)
|
err = imagerequest.ValidateDimension(field, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,27 +45,17 @@ func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
|||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() 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{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: sessMgr,
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
encGen: encGen,
|
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
||||||
csrfProtect: protect,
|
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generator form field names, and a value that is not a number.
|
// Generator form field names, and a value that is not a number.
|
||||||
@@ -125,10 +125,10 @@ func TestGeneratePostRejectsBadField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
// TestBuildGenerateRequestDefaultAndLimits verifies that an empty quality
|
||||||
// takes the default, and that the largest accepted width, height, quality and
|
// takes the default, and that the largest accepted width, height, quality and
|
||||||
// ttl are accepted with an expiry still in the future.
|
// ttl are accepted with an expiry still in the future.
|
||||||
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
func TestBuildGenerateRequestDefaultAndLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
parsed, err := url.Parse(testSourceURL)
|
parsed, err := url.Parse(testSourceURL)
|
||||||
@@ -136,19 +136,19 @@ func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
|||||||
t.Fatalf("url.Parse() error = %v", err)
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
req, _, _, err := buildGenerateRequest(parsed, url.Values{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("empty form: error = %v", err)
|
t.Fatalf("empty form: error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Quality != encurl.DefaultQuality {
|
if req.Transform.Quality != encurl.DefaultQuality {
|
||||||
t.Errorf("empty quality gave %d, want %d",
|
t.Errorf("empty quality gave %d, want %d",
|
||||||
payload.Quality, encurl.DefaultQuality)
|
req.Transform.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
_, expiresAt, _, err := buildGenerateRequest(parsed, url.Values{
|
||||||
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
widthField: {strconv.Itoa(imagerequest.MaxDimension)},
|
||||||
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
heightField: {strconv.Itoa(imagerequest.MaxDimension)},
|
||||||
qualityField: {strconv.Itoa(maxQuality)},
|
qualityField: {strconv.Itoa(maxQuality)},
|
||||||
ttlField: {strconv.Itoa(maxTTL)},
|
ttlField: {strconv.Itoa(maxTTL)},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,15 +23,15 @@ func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||||
config: &config.Config{SigningKey: testSigningKey},
|
config: &config.Config{SigningKey: testSigningKey},
|
||||||
sessMgr: sessMgr,
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
submittedKeys := []string{wrongKey, testSigningKey}
|
submittedKeys := []string{wrongKey, testSigningKey}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,8 +233,13 @@ func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
|
|||||||
|
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
sessMgr, err := session.NewManagerWithDatabase(
|
sessMgr, err := session.NewManagerWithDatabase(
|
||||||
t.Context(), testSigningKey, db)
|
t.Context(), keys.SessionHashKey, keys.SessionBlockKey, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -314,7 +320,7 @@ func TestGeneratePost_URLServesImage(t *testing.T) {
|
|||||||
sourceURLField: {"https://" + signedHost + photoPath},
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
widthField: {"50"},
|
widthField: {"50"},
|
||||||
heightField: {"50"},
|
heightField: {"50"},
|
||||||
formatField: {string(imgcache.FormatJPEG)},
|
formatField: {string(imagerequest.FormatJPEG)},
|
||||||
})
|
})
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -347,7 +353,7 @@ func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
|
|||||||
sourceURLField: {"https://" + signedHost + photoPath},
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
widthField: {"50"},
|
widthField: {"50"},
|
||||||
heightField: {"50"},
|
heightField: {"50"},
|
||||||
formatField: {string(imgcache.FormatJPEG)},
|
formatField: {string(imagerequest.FormatJPEG)},
|
||||||
ttlField: {"1"},
|
ttlField: {"1"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,27 +33,17 @@ func newBodyLimitTestRouter(
|
|||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() 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{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: sessMgr,
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
encGen: encGen,
|
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
||||||
csrfProtect: protect,
|
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
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/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.CacheConfig{
|
||||||
|
StateDir: tb.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewService() 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(imgcache.CacheHit) {
|
||||||
|
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imgcache.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(imgcache.CacheHit) {
|
||||||
|
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imgcache.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,15 +9,10 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/seal"
|
"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
|
// newCSRFProtect builds the CSRF-protection middleware for the
|
||||||
// state-mutating HTML form routes. The token lives in its own cookie,
|
// state-mutating HTML form routes. The token lives in its own cookie,
|
||||||
// independent of the session cookie, so it also protects the login POST
|
// independent of the session cookie, so it also protects the login POST
|
||||||
// where no session exists yet (login CSRF).
|
// where no session exists yet (login CSRF). key signs that cookie.
|
||||||
//
|
//
|
||||||
// When plaintext is true (local HTTP development), requests are marked
|
// When plaintext is true (local HTTP development), requests are marked
|
||||||
// plaintext so the library neither demands an https Referer nor sets a
|
// plaintext so the library neither demands an https Referer nor sets a
|
||||||
@@ -25,13 +20,8 @@ const csrfKeySalt = "pixa-csrf-v1"
|
|||||||
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
||||||
// the library enforces its https Referer origin check.
|
// the library enforces its https Referer origin check.
|
||||||
func newCSRFProtect(
|
func newCSRFProtect(
|
||||||
signingKey string, plaintext bool,
|
key [seal.KeySize]byte, plaintext bool,
|
||||||
) (func(http.Handler) http.Handler, error) {
|
) func(http.Handler) http.Handler {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
protect := csrf.Protect(
|
protect := csrf.Protect(
|
||||||
key[:],
|
key[:],
|
||||||
csrf.Path("/"),
|
csrf.Path("/"),
|
||||||
@@ -40,7 +30,7 @@ func newCSRFProtect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !plaintext {
|
if !plaintext {
|
||||||
return protect, nil
|
return protect
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
@@ -49,7 +39,7 @@ func newCSRFProtect(
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
||||||
})
|
})
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF returns the CSRF-protection middleware for the login and generator
|
// CSRF returns the CSRF-protection middleware for the login and generator
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
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/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.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 := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: fetcher,
|
||||||
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewService() 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Errors for an Accept header that an auto URL cannot be served for.
|
// Errors for an Accept header that an auto URL cannot be served for.
|
||||||
@@ -18,16 +18,17 @@ var (
|
|||||||
"image/jxl, image/avif, image/webp or image/jpeg")
|
"image/jxl, image/avif, image/webp or image/jpeg")
|
||||||
)
|
)
|
||||||
|
|
||||||
// chooseAutoFormat replaces the format auto in req with the format
|
// chooseAutoFormat returns the format to serve for the requested format: for
|
||||||
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
|
// 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
|
||||||
// response, which then depends on that header. It answers 400 for an Accept
|
// 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,
|
// header that is not valid and 406 for one that allows none of the formats,
|
||||||
// and reports whether req can be served. Any other format is left as it is.
|
// and reports whether the image can be served.
|
||||||
func (s *Handlers) chooseAutoFormat(
|
func (s *Handlers) chooseAutoFormat(
|
||||||
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest,
|
w http.ResponseWriter, r *http.Request, requested imagerequest.Format,
|
||||||
) bool {
|
) (imagerequest.Format, bool) {
|
||||||
if req.Format != imgcache.FormatAuto {
|
if requested != imagerequest.FormatAuto {
|
||||||
return true
|
return requested, true
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Vary", "Accept")
|
w.Header().Add("Vary", "Accept")
|
||||||
@@ -36,18 +37,16 @@ func (s *Handlers) chooseAutoFormat(
|
|||||||
if errors.Is(err, errNotAcceptable) {
|
if errors.Is(err, errNotAcceptable) {
|
||||||
s.respondError(w, err.Error(), http.StatusNotAcceptable)
|
s.respondError(w, err.Error(), http.StatusNotAcceptable)
|
||||||
|
|
||||||
return false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, err.Error(), http.StatusBadRequest)
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Format = format
|
return format, true
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatForAccept returns the format an auto URL is served in for the Accept
|
// formatForAccept returns the format an auto URL is served in for the Accept
|
||||||
@@ -56,26 +55,26 @@ func (s *Handlers) chooseAutoFormat(
|
|||||||
// specific entry of image/jpeg, image/* and */* allows it, or when it names
|
// 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
|
// 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.
|
// clients that cannot show them send image/* and */* too.
|
||||||
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
func formatForAccept(accept string) (imagerequest.Format, error) {
|
||||||
qualities, err := parseAccept(accept)
|
qualities, err := parseAccept(accept)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(qualities) == 0 {
|
if len(qualities) == 0 {
|
||||||
return imgcache.FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/jxl"] > 0 {
|
if qualities["image/jxl"] > 0 {
|
||||||
return imgcache.FormatJXL, nil
|
return imagerequest.FormatJXL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/avif"] > 0 {
|
if qualities["image/avif"] > 0 {
|
||||||
return imgcache.FormatAVIF, nil
|
return imagerequest.FormatAVIF, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/webp"] > 0 {
|
if qualities["image/webp"] > 0 {
|
||||||
return imgcache.FormatWebP, nil
|
return imagerequest.FormatWebP, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For JPEG, the most specific entry the header has decides
|
// For JPEG, the most specific entry the header has decides
|
||||||
@@ -89,7 +88,7 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if quality > 0 {
|
if quality > 0 {
|
||||||
return imgcache.FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errNotAcceptable
|
return "", errNotAcceptable
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The content types the tests below expect.
|
// The content types the tests below expect.
|
||||||
@@ -33,24 +32,24 @@ func TestFormatForAccept(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
accept string
|
accept string
|
||||||
want imgcache.ImageFormat
|
want imagerequest.Format
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"AVIF-capable browser",
|
{"AVIF-capable browser",
|
||||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
imgcache.FormatAVIF, nil},
|
imagerequest.FormatAVIF, nil},
|
||||||
{"WebP-capable browser",
|
{"WebP-capable browser",
|
||||||
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
||||||
imgcache.FormatWebP, nil},
|
imagerequest.FormatWebP, nil},
|
||||||
{"WebP only", webpType, imgcache.FormatWebP, nil},
|
{"WebP only", webpType, imagerequest.FormatWebP, nil},
|
||||||
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
|
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imagerequest.FormatJPEG, nil},
|
||||||
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
|
{"wildcard only", "*/*", imagerequest.FormatJPEG, nil},
|
||||||
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
|
{"image wildcard only", "image/*", imagerequest.FormatJPEG, nil},
|
||||||
{"absent", "", imgcache.FormatJPEG, nil},
|
{"absent", "", imagerequest.FormatJPEG, nil},
|
||||||
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
|
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imagerequest.FormatWebP, nil},
|
||||||
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
||||||
imgcache.FormatJPEG, nil},
|
imagerequest.FormatJPEG, nil},
|
||||||
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
|
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imagerequest.FormatAVIF, nil},
|
||||||
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
|
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
|
||||||
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
|
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
|
||||||
{"PNG only", "image/png", "", errNotAcceptable},
|
{"PNG only", "image/png", "", errNotAcceptable},
|
||||||
@@ -76,28 +75,33 @@ func TestFormatForAccept(t *testing.T) {
|
|||||||
|
|
||||||
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
|
// 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
|
// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in
|
||||||
// the format auto, made with h's image service and generator.
|
// 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.
|
||||||
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatAuto,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatAuto,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatAuto,
|
Transform: imagerequest.Transform{
|
||||||
ExpiresAt: time.Now().Add(time.Minute).Unix(),
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
})
|
Format: imagerequest.FormatAuto,
|
||||||
|
},
|
||||||
|
}, time.Now().Add(time.Minute))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -182,17 +186,18 @@ func TestFormatAuto_ChosenFromAccept(t *testing.T) {
|
|||||||
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedForAVIF := generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatAVIF,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatAVIF,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
|
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
|
||||||
if target == signedForAVIF {
|
if target == signedForAVIF {
|
||||||
|
|||||||
+19
-130
@@ -4,7 +4,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,13 +11,13 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/allowlist"
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines dependencies for Handlers.
|
// Params defines dependencies for Handlers.
|
||||||
@@ -27,70 +26,48 @@ type Params struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
|
Keys seal.Keys
|
||||||
|
|
||||||
// Fetcher, when provided, fetches upstream images in place of the
|
ImageService *imgcache.Service
|
||||||
// fetcher the handlers build from the config. Only tests provide one;
|
SessionManager *session.Manager
|
||||||
// pixad does not.
|
EncryptedURLGenerator *encurl.Generator
|
||||||
Fetcher httpfetcher.Fetcher `optional:"true"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
db *database.Database
|
|
||||||
config *config.Config
|
config *config.Config
|
||||||
fetcher httpfetcher.Fetcher
|
|
||||||
imgSvc *imgcache.Service
|
imgSvc *imgcache.Service
|
||||||
imgCache *imgcache.Cache
|
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
csrfProtect func(http.Handler) http.Handler
|
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
|
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
|
||||||
// reports whether a URL's host is on that list.
|
// reports whether a URL's host is on that list.
|
||||||
refererBlocklist *allowlist.HostAllowList
|
refererBlocklist *allowlist.HostAllowList
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
func New(_ fx.Lifecycle, params Params) *Handlers {
|
||||||
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
return &Handlers{
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Handlers{
|
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
fetcher: params.Fetcher,
|
imgSvc: params.ImageService,
|
||||||
csrfProtect: csrfProtect,
|
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),
|
||||||
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
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
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
@@ -99,94 +76,6 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|||||||
return s.imgSvc.WaitForProcessing(ctx)
|
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) {
|
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -18,15 +18,19 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testFixtures contains test data.
|
// testFixtures contains test data.
|
||||||
type testFixtures struct {
|
type testFixtures struct {
|
||||||
handler *Handlers
|
handler *Handlers
|
||||||
service *imgcache.Service
|
|
||||||
goodHost string
|
goodHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,8 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,8 +61,11 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
SigningKey: "test-signing-key-must-be-32-chars",
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{goodHost},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -66,11 +74,12 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
imgSvc: svc,
|
imgSvc: svc,
|
||||||
log: slog.Default(),
|
log: slog.Default(),
|
||||||
|
signer: signature.New("test-signing-key-must-be-32-chars"),
|
||||||
|
allowlist: allowlist.New([]string{goodHost}),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &testFixtures{
|
return &testFixtures{
|
||||||
handler: h,
|
handler: h,
|
||||||
service: svc,
|
|
||||||
goodHost: goodHost,
|
goodHost: goodHost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,10 +130,9 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(
|
func (f *mockFetcher) Fetch(
|
||||||
_ context.Context, url string,
|
_ context.Context, source imagerequest.Source,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
// Remove https:// prefix
|
path := source.Host + source.Path
|
||||||
path := url[8:] // Remove "https://"
|
|
||||||
|
|
||||||
data, err := fs.ReadFile(f.fs, path)
|
data, err := fs.ReadFile(f.fs, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+149
-97
@@ -7,13 +7,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImage handles the main image proxy route:
|
// HandleImage handles the main image proxy route:
|
||||||
@@ -25,17 +28,17 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req, ok := s.parseImageRequest(w, r)
|
req, sig, expires, ok := s.parseImageRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate signature if required
|
// Validate signature if required
|
||||||
err := s.imgSvc.ValidateRequest(req)
|
err := s.validateSignature(req, sig, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("signature validation failed",
|
s.log.Warn("signature validation failed",
|
||||||
"host", req.SourceHost,
|
"host", req.Source.Host,
|
||||||
"path", req.SourcePath,
|
"path", req.Source.Path,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -43,27 +46,7 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The signature covers the format auto, not the format chosen
|
s.serveImage(w, r, req, expires)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,17 +63,17 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseImageRequest parses the image URL's path and query parameters into
|
// parseImageRequest parses the image URL's path and query parameters into
|
||||||
// an ImageRequest. On invalid input it writes an error response and
|
// an image request, and returns it with the URL's signature and expiry. On
|
||||||
// returns false.
|
// invalid input it writes an error response and returns false.
|
||||||
func (s *Handlers) parseImageRequest(
|
func (s *Handlers) parseImageRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imgcache.ImageRequest, bool) {
|
) (*imagerequest.Request, string, time.Time, bool) {
|
||||||
// The path is parsed decoded once, as r.URL.Path holds it, and not from
|
// 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
|
// 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
|
// 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
|
// the %3F before a source query when, say, a "/" in the query is written
|
||||||
// as %2F
|
// as %2F
|
||||||
parsed, err := imgcache.ParseImageURL(r.URL.Path)
|
req, err := imagerequest.ParseImageURL(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to parse image URL",
|
s.log.Warn("failed to parse image URL",
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
@@ -98,12 +81,9 @@ func (s *Handlers) parseImageRequest(
|
|||||||
)
|
)
|
||||||
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to ImageRequest
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
// Parse signature params from query string. r.URL.Query() would silently
|
// 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
|
// 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
|
// at 85; a query string that cannot be decoded is refused instead. A
|
||||||
@@ -114,7 +94,7 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
||||||
r.URL.RawQuery, err), http.StatusBadRequest)
|
r.URL.RawQuery, err), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, values := range query {
|
for name, values := range query {
|
||||||
@@ -122,17 +102,15 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
||||||
name), http.StatusBadRequest)
|
name), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Signature = query.Get("sig")
|
expires, err := parseExpires(query)
|
||||||
|
|
||||||
req.Expires, err = parseExpires(query)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, err.Error(), http.StatusBadRequest)
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse optional quality and fit params. Only a q missing from the URL is
|
// Parse optional quality and fit params. Only a q missing from the URL is
|
||||||
@@ -144,51 +122,76 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, `invalid q: not a number, got ""`,
|
s.respondError(w, `invalid q: not a number, got ""`,
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Quality, err = parseFormInt(query, "q",
|
req.Transform.Quality, err = parseFormInt(query, "q",
|
||||||
encurl.DefaultQuality, minQuality, maxQuality)
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
// 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
|
// 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.
|
// empty fit as missing, so an empty one is refused here.
|
||||||
req.FitMode = imgcache.FitMode(query.Get("fit"))
|
req.Transform.FitMode = imagerequest.Fit(query.Get("fit"))
|
||||||
if query.Has("fit") && req.FitMode == "" {
|
if query.Has("fit") && req.Transform.FitMode == "" {
|
||||||
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
if req.FitMode == "" {
|
if req.Transform.FitMode == "" {
|
||||||
req.FitMode = imgcache.FitCover
|
req.Transform.FitMode = imagerequest.FitCover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||||
// route. Dimensions are already bounded by the path parser above; this
|
// route. Dimensions are already bounded by the path parser above; this
|
||||||
// also rejects an unrecognized fit mode with 400 instead of letting it
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||||
// reach the processor as a 500.
|
// reach the processor as a 500.
|
||||||
err = imgcache.ValidateImageRequest(req)
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, "invalid image request: "+err.Error(),
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, true
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
// 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 takes
|
// missing from the URL gives the zero time, which the signature check refuses
|
||||||
// as no expiration. An exp in the URL that is not a whole number, an empty
|
// as a missing exp. An exp in the URL that is not a whole number, an empty
|
||||||
// one included, is an error naming exp and the value.
|
// one included, is an error naming exp and the value.
|
||||||
func parseExpires(query url.Values) (time.Time, error) {
|
func parseExpires(query url.Values) (time.Time, error) {
|
||||||
if !query.Has("exp") {
|
if !query.Has("exp") {
|
||||||
@@ -216,55 +219,73 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondImageError maps image retrieval errors to HTTP responses.
|
// 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.
|
||||||
func (s *Handlers) respondImageError(
|
func (s *Handlers) respondImageError(
|
||||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
w http.ResponseWriter, req *imagerequest.Request, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error("failed to get image",
|
// An upstream body whose first bytes are not those of the format its
|
||||||
"host", req.SourceHost,
|
// Content-Type names is the upstream's error, not pixa's: the image
|
||||||
"path", req.SourcePath,
|
// service has logged it as a refused source.
|
||||||
"error", err,
|
if errors.Is(err, magic.ErrMagicByteMismatch) ||
|
||||||
)
|
errors.Is(err, magic.ErrUnknownFormat) ||
|
||||||
|
errors.Is(err, magic.ErrNotEnoughData) {
|
||||||
// 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",
|
s.respondError(w, "unsupported source format",
|
||||||
http.StatusUnsupportedMediaType)
|
http.StatusUnsupportedMediaType)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
|
var (
|
||||||
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
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, imgcache.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 "+
|
||||||
"computed from its proportions is above %d",
|
"computed from its proportions is above %d",
|
||||||
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
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)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.log.Warn("failed to get image",
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
s.respondError(w, message, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cacheControl returns the Cache-Control header for an image served through a
|
// cacheControl returns the Cache-Control header for an image served through a
|
||||||
@@ -321,13 +342,34 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeImageResponse writes headers and streams the image content,
|
// serveImage serves the image that req asks for. Both image routes call it
|
||||||
// handling conditional and HEAD requests.
|
// once they have the request and its URL's expiry, which ends the
|
||||||
func (s *Handlers) writeImageResponse(
|
// 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(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
req *imagerequest.Request, expires time.Time,
|
||||||
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
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
@@ -336,7 +378,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", cacheControl(expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if notModified(w, r, resp.ETag) {
|
if notModified(w, r, resp.ETag) {
|
||||||
@@ -353,21 +395,31 @@ func (s *Handlers) writeImageResponse(
|
|||||||
// Stream the response
|
// Stream the response
|
||||||
w.WriteHeader(http.StatusOK)
|
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)
|
servedBytes, err := io.Copy(w, resp.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to write response",
|
s.log.Warn("failed to write response",
|
||||||
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"served_bytes", servedBytes,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log cache status and timing after serving
|
// Log cache status and timing after serving
|
||||||
duration := time.Since(startTime)
|
duration := time.Since(startTime)
|
||||||
s.log.Info("image served",
|
s.log.Info("image served",
|
||||||
"request_id", middleware.GetReqID(r.Context()),
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
"cache_key", cacheKey,
|
"cache_key", imagerequest.CacheKey(req),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
"cache_status", resp.CacheStatus,
|
"cache_status", resp.CacheStatus,
|
||||||
"duration_ms", duration.Milliseconds(),
|
"duration_ms", duration.Milliseconds(),
|
||||||
"format", req.Format,
|
"format", req.Transform.Format,
|
||||||
"served_bytes", servedBytes,
|
"served_bytes", servedBytes,
|
||||||
"fetched_bytes", resp.FetchedBytes,
|
"fetched_bytes", resp.FetchedBytes,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"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.
|
// photoPath is the path of the JPEG that newSignedHostServer serves.
|
||||||
@@ -32,7 +38,8 @@ func newSignedHostServer(
|
|||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -44,22 +51,30 @@ func newSignedHostServer(
|
|||||||
Fetcher: newMockFetcher(fstest.MapFS{
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
||||||
}),
|
}),
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
Logger: log,
|
Logger: log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: log,
|
log: log,
|
||||||
imgSvc: svc,
|
imgSvc: svc,
|
||||||
encGen: encGen,
|
encGen: encGen,
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -105,17 +120,18 @@ func getMaxAge(t *testing.T, srv http.Handler, target string) int {
|
|||||||
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxAge := getMaxAge(t, srv, signedURL)
|
maxAge := getMaxAge(t, srv, signedURL)
|
||||||
if maxAge < 50 || maxAge > 60 {
|
if maxAge < 50 || maxAge > 60 {
|
||||||
@@ -168,13 +184,13 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
expiresAt int64
|
expires time.Time
|
||||||
wantAtLeast int
|
wantAtLeast int
|
||||||
wantAtMost int
|
wantAtMost int
|
||||||
}{
|
}{
|
||||||
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
|
{"60 second TTL", time.Now().Add(time.Minute), 50, 60},
|
||||||
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
|
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour), 31536000, 31536000},
|
||||||
{"no TTL", 0, 31536000, 31536000},
|
{"no TTL", time.Time{}, 31536000, 31536000},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -183,14 +199,16 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
ExpiresAt: tt.expiresAt,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
})
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, tt.expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
@@ -23,14 +32,15 @@ import (
|
|||||||
const allowlistedHost = "allowed.example.com"
|
const allowlistedHost = "allowed.example.com"
|
||||||
|
|
||||||
// newImageRoute returns the image route of a Handlers whose service fetches
|
// newImageRoute returns the image route of a Handlers whose service fetches
|
||||||
// with fetcher and checks signatures with testSigningKey.
|
// with fetcher, and which checks signatures with testSigningKey.
|
||||||
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -38,14 +48,22 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{allowlistedHost},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{
|
||||||
|
imgSvc: svc,
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{allowlistedHost}),
|
||||||
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", h.HandleImage())
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
@@ -54,7 +72,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
||||||
// each of hosts, and answers any other URL with an upstream error.
|
// each of hosts, and answers any other source with an upstream error.
|
||||||
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -81,19 +99,27 @@ func photoURLWithSig(host, sig string, expires time.Time) string {
|
|||||||
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
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
|
// photoSignature returns the signature of photoURL(host) at the default
|
||||||
// quality and fit, made with key and expiring at expires.
|
// quality and fit, made with key and expiring at expires.
|
||||||
func photoSignature(key, host string, expires time.Time) string {
|
func photoSignature(key, host string, expires time.Time) string {
|
||||||
return signature.New(key).Sign(&signature.Request{
|
return signature.New(key).Sign(photoRequest(host), expires)
|
||||||
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.
|
// sendGet sends a GET for target to route and returns the response.
|
||||||
@@ -164,7 +190,12 @@ func TestHandleImage_ErrorAnswers(t *testing.T) {
|
|||||||
photos := newPhotoFetcher(t,
|
photos := newPhotoFetcher(t,
|
||||||
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
||||||
// The real fetcher refuses localhost before any lookup or connection.
|
// The real fetcher refuses localhost before any lookup or connection.
|
||||||
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
realFetcher := httpfetcher.New(&httpfetcher.Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
})
|
||||||
|
|
||||||
exp := time.Now().Add(time.Hour)
|
exp := time.Now().Add(time.Hour)
|
||||||
expired := time.Now().Add(-time.Hour)
|
expired := time.Now().Add(-time.Hour)
|
||||||
@@ -269,10 +300,9 @@ func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
|||||||
// host can be, and checks that each is refused with 400 naming the host as
|
// 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
|
// decoded: allowlistedHost with a character after it that a URL host cannot
|
||||||
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
||||||
// around something that is not an IPv6 address. A "?" or "#" after
|
// around something that is not an IPv6 address. Later steps put the host
|
||||||
// allowlistedHost would otherwise end the host when the source URL is read,
|
// unparsed into the allowlist check, the cache key and the upstream URL, so
|
||||||
// so the request would pass the allowlist and be fetched and cached under a
|
// such a host is refused first.
|
||||||
// host that is not the one fetched.
|
|
||||||
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -343,26 +373,27 @@ func TestHandleImage_HostForms_Served(t *testing.T) {
|
|||||||
|
|
||||||
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
||||||
// source path or source query holds a "#", written as %23, and checks that
|
// 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. The
|
// each is refused with 400, the one signed for that source path included.
|
||||||
// source URL would otherwise end at the "#" when it is fetched, so each text
|
// A "#" starts a URL's fragment, so neither the source path nor the source
|
||||||
// after it would fetch the image at photoPath and cache it under keys of its
|
// query may hold one.
|
||||||
// own.
|
|
||||||
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
||||||
exp := time.Now().Add(time.Hour)
|
exp := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath + "#x",
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath + "#x",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: string(imgcache.FitCover),
|
FitMode: imagerequest.FitCover,
|
||||||
Expires: exp,
|
},
|
||||||
})
|
}, exp)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -388,3 +419,168 @@ 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,11 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +37,47 @@ func getImage(t *testing.T, fix *testFixtures, target string) int {
|
|||||||
return rec.Code
|
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
|
// 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
|
// 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.
|
// sends that signature with each q and fit below.
|
||||||
@@ -44,16 +86,18 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/images/photo.jpg",
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: "/images/photo.jpg",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: string(imgcache.FitCover),
|
FitMode: imagerequest.FitCover,
|
||||||
Expires: expires,
|
},
|
||||||
})
|
}, expires)
|
||||||
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
||||||
signedHost, sig, expires.Unix())
|
signedHost, sig, expires.Unix())
|
||||||
|
|
||||||
@@ -80,17 +124,17 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by
|
||||||
// service's signed-URL generator to the route.
|
// generateSignedURL to the route.
|
||||||
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
quality int
|
quality int
|
||||||
fitMode imgcache.FitMode
|
fitMode imagerequest.Fit
|
||||||
}{
|
}{
|
||||||
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
{"quality 40 and fit contain", 40, imagerequest.FitContain},
|
||||||
{"quality and fit unset", 0, ""},
|
{"quality and fit unset", 0, ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,21 +142,21 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
||||||
|
&imagerequest.Request{
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
Source: imagerequest.Source{
|
||||||
SourceHost: signedHost,
|
Host: signedHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
Path: "/images/photo.jpg",
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: tt.quality,
|
Quality: tt.quality,
|
||||||
FitMode: tt.fitMode,
|
FitMode: tt.fitMode,
|
||||||
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := getImage(t, fix, signedURL)
|
status := getImage(t, setupTestHandler(t), signedURL)
|
||||||
if status != http.StatusBadGateway {
|
if status != http.StatusBadGateway {
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
@@ -171,10 +215,10 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
||||||
// by the service's signed-URL generator for source URLs with a query to the
|
// by generateSignedURL for source URLs with a query to the route, through a
|
||||||
// route, through a chi router: chi routes on the path as the client wrote it
|
// chi router: chi routes on the path as the client wrote it whenever that is
|
||||||
// whenever that is not Go's own encoding of it. A 502 means the signature
|
// not Go's own encoding of it. A 502 means the signature passed, so the route
|
||||||
// passed, so the route read the source path and query that were signed.
|
// read the source path and query that were signed.
|
||||||
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -194,20 +238,20 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
||||||
|
&imagerequest.Request{
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
Source: imagerequest.Source{
|
||||||
SourceHost: signedHost,
|
Host: signedHost,
|
||||||
SourcePath: photoPath,
|
Path: photoPath,
|
||||||
SourceQuery: tt.query,
|
Query: tt.query,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := getImage(t, fix, signedURL)
|
status := getImage(t, setupTestHandler(t), signedURL)
|
||||||
if status != http.StatusBadGateway {
|
if status != http.StatusBadGateway {
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
@@ -224,17 +268,19 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
|
Path: photoPath,
|
||||||
Width: 50,
|
Query: "url=https://origin.example.com/my%20cat.jpg",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: string(imgcache.FitCover),
|
FitMode: imagerequest.FitCover,
|
||||||
Expires: expires,
|
},
|
||||||
})
|
}, expires)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -266,7 +312,7 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|||||||
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
||||||
// source query that each hold %25, the percent-escape of a literal "%", and
|
// 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
|
// 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) {
|
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -288,17 +334,19 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: tt.sourcePath,
|
Host: signedHost,
|
||||||
SourceQuery: tt.sourceQuery,
|
Path: tt.sourcePath,
|
||||||
Width: 50,
|
Query: tt.sourceQuery,
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: string(imgcache.FitCover),
|
FitMode: imagerequest.FitCover,
|
||||||
Expires: expires,
|
},
|
||||||
})
|
}, expires)
|
||||||
|
|
||||||
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
||||||
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
||||||
@@ -309,23 +357,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()
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
||||||
|
&imagerequest.Request{
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
Source: imagerequest.Source{
|
||||||
SourceHost: signedHost,
|
Host: signedHost,
|
||||||
SourcePath: tt.sourcePath,
|
Path: tt.sourcePath,
|
||||||
SourceQuery: tt.sourceQuery,
|
Query: tt.sourceQuery,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := getImage(t, fix, signedURL)
|
status := getImage(t, setupTestHandler(t), signedURL)
|
||||||
if status != http.StatusBadGateway {
|
if status != http.StatusBadGateway {
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-118
@@ -2,19 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
||||||
@@ -26,152 +20,59 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
req, expires, ok := s.parseImageEncRequest(w, r)
|
||||||
start := time.Now()
|
if !ok {
|
||||||
|
|
||||||
req, ok := s.parseImageEncRequest(w, r)
|
|
||||||
if !ok || !s.chooseAutoFormat(w, r, req) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the request
|
s.serveImage(w, r, req, expires)
|
||||||
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
|
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
||||||
// ImageRequest and checks it. On a token that is missing, does not decrypt,
|
// image request and checks it, and returns it with the token's expiry. On a
|
||||||
// has expired or asks for something not valid, it writes an error response
|
// token that is missing, does not decrypt, has expired or asks for something
|
||||||
// and returns false.
|
// not valid, it writes an error response and returns false.
|
||||||
func (s *Handlers) parseImageEncRequest(
|
func (s *Handlers) parseImageEncRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imgcache.ImageRequest, bool) {
|
) (*imagerequest.Request, time.Time, bool) {
|
||||||
// Extract token from URL
|
// Extract token from URL
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
s.respondError(w, "missing token", http.StatusBadRequest)
|
s.respondError(w, "missing token", http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and validate the payload
|
// Decrypt and validate the payload
|
||||||
payload, err := s.encGen.Parse(token)
|
req, expires, err := s.encGen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, encurl.ErrExpired) {
|
if errors.Is(err, encurl.ErrExpired) {
|
||||||
s.log.Debug("encrypted URL expired", "error", err)
|
s.log.Debug("encrypted URL expired", "error", err)
|
||||||
s.respondError(w, "URL has expired", http.StatusGone)
|
s.respondError(w, "URL has expired", http.StatusGone)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Debug("failed to decrypt URL", "error", err)
|
s.log.Debug("invalid encrypted URL", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert payload to ImageRequest
|
|
||||||
req := payload.ToImageRequest()
|
|
||||||
|
|
||||||
// Apply the same dimension and fit-mode bounds as the plain image
|
// Apply the same dimension and fit-mode bounds as the plain image
|
||||||
// route: a sealed payload is trusted for its origin, not for staying
|
// route: a sealed payload is trusted for its origin, not for staying
|
||||||
// within limits, so an over-limit size or unknown fit mode is a 400
|
// within limits. This refuses an over-limit size before the source is
|
||||||
// here rather than an out-of-memory or a 500 from the processor.
|
// fetched. An unknown fit mode is a 400 here rather than a 500 from the
|
||||||
err = imgcache.ValidateImageRequest(req)
|
// processor.
|
||||||
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Debug("encrypted URL failed validation", "error", err)
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, true
|
return req, expires, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newEncTestServer builds a router serving the encrypted-URL route with a
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||||
@@ -20,11 +22,13 @@ import (
|
|||||||
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
encGen: encGen,
|
encGen: encGen,
|
||||||
@@ -54,12 +58,15 @@ func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: "cdn.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/photo.jpg",
|
Host: "cdn.example.com",
|
||||||
Width: 100000,
|
Path: "/photo.jpg",
|
||||||
Height: 100000,
|
},
|
||||||
})
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 100000, Height: 100000},
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -79,13 +86,16 @@ func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: "cdn.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/photo.jpg",
|
Host: "cdn.example.com",
|
||||||
Width: 800,
|
Path: "/photo.jpg",
|
||||||
Height: 600,
|
},
|
||||||
FitMode: imgcache.FitMode("bogus"),
|
Transform: imagerequest.Transform{
|
||||||
})
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
|
FitMode: imagerequest.Fit("bogus"),
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"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
|
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
||||||
@@ -59,32 +61,34 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
photo := encurl.Payload{
|
photo := imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := h.encGen.Generate(&photo)
|
valid, err := h.encGen.Generate(&photo, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expiredPhoto := photo
|
expired, err := h.encGen.Generate(&photo, time.Now().Add(-time.Minute))
|
||||||
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
|
|
||||||
|
|
||||||
expired, err := h.encGen.Generate(&expiredPhoto)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
|
keys, err := seal.DeriveKeys("another-signing-key-fedcba9876543210")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
otherKey, err := otherGen.Generate(&photo)
|
otherGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
|
otherKey, err := otherGen.Generate(&photo, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,16 +58,18 @@ func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
|
|||||||
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
|
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJXL),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJXL,
|
||||||
Quality: encurl.DefaultQuality,
|
Quality: encurl.DefaultQuality,
|
||||||
FitMode: string(imgcache.FitCover),
|
FitMode: imagerequest.FitCover,
|
||||||
Expires: expires,
|
},
|
||||||
})
|
}, expires)
|
||||||
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
|
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
|
||||||
|
|
||||||
for _, size := range []string{"50x50.jxl", "50x50"} {
|
for _, size := range []string{"50x50.jxl", "50x50"} {
|
||||||
@@ -83,12 +85,15 @@ func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
})
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jxlType is the content type of JPEG XL.
|
// jxlType is the content type of JPEG XL.
|
||||||
@@ -22,14 +22,14 @@ func TestFormatForAccept_JPEGXL(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
accept string
|
accept string
|
||||||
want imgcache.ImageFormat
|
want imagerequest.Format
|
||||||
}{
|
}{
|
||||||
{"JPEG XL-capable browser",
|
{"JPEG XL-capable browser",
|
||||||
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
|
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imagerequest.FormatJXL},
|
||||||
{"JPEG XL only", jxlType, imgcache.FormatJXL},
|
{"JPEG XL only", jxlType, imagerequest.FormatJXL},
|
||||||
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
||||||
imgcache.FormatJXL},
|
imagerequest.FormatJXL},
|
||||||
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
|
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imagerequest.FormatAVIF},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"sneak.berlin/go/pixa/internal/allowlist"
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"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
|
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
|
||||||
@@ -29,11 +34,11 @@ type countingFetcher struct {
|
|||||||
|
|
||||||
// Fetch counts the fetch and passes it on.
|
// Fetch counts the fetch and passes it on.
|
||||||
func (f *countingFetcher) Fetch(
|
func (f *countingFetcher) Fetch(
|
||||||
ctx context.Context, url string,
|
ctx context.Context, source imagerequest.Source,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
f.fetches.Add(1)
|
f.fetches.Add(1)
|
||||||
|
|
||||||
return f.Fetcher.Fetch(ctx, url)
|
return f.Fetcher.Fetch(ctx, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRefererRoutes returns both image routes of a Handlers whose
|
// newRefererRoutes returns both image routes of a Handlers whose
|
||||||
@@ -50,7 +55,8 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
|
|||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -58,22 +64,29 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
|
|||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{allowlistedHost},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
imgSvc: svc,
|
imgSvc: svc,
|
||||||
encGen: encGen,
|
encGen: encGen,
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{allowlistedHost}),
|
||||||
refererBlocklist: allowlist.New(
|
refererBlocklist: allowlist.New(
|
||||||
[]string{"leech.example", ".hotlinker.example"}),
|
[]string{"leech.example", ".hotlinker.example"}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,26 +13,26 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
||||||
// JPEG at photoPath on signedHost at 50x50, made with h's image service.
|
// JPEG at photoPath on signedHost at 50x50. It is signed with testSigningKey,
|
||||||
func signedPhotoURL(t *testing.T, h *Handlers) string {
|
// 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 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
return generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, 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
|
// encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the
|
||||||
@@ -40,13 +40,16 @@ func signedPhotoURL(t *testing.T, h *Handlers) string {
|
|||||||
func encPhotoURL(t *testing.T, h *Handlers) string {
|
func encPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
})
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,51 +11,48 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestServerBusyAnswers503 checks that both image routes answer 503 with
|
// TestServerBusyAnswers503 checks that respondImageError, which answers the
|
||||||
// a clear error when the image service gives up waiting for a free upstream
|
// errors of both image routes, answers 503 with a clear error when the image
|
||||||
// connection or processing slot, wrapped as the service wraps them.
|
// service gives up waiting for a free upstream connection or processing slot,
|
||||||
|
// wrapped as the service wraps them.
|
||||||
func TestServerBusyAnswers503(t *testing.T) {
|
func TestServerBusyAnswers503(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.jpg"},
|
||||||
|
}
|
||||||
|
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
||||||
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
||||||
} {
|
} {
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, req, err)
|
h.respondImageError(rec, req, err)
|
||||||
|
|
||||||
encrypted := httptest.NewRecorder()
|
|
||||||
h.handleImageError(encrypted, err)
|
|
||||||
|
|
||||||
for route, rec := range map[string]*httptest.ResponseRecorder{
|
|
||||||
"/v1/image/": plain, "/v1/e/": encrypted,
|
|
||||||
} {
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("%s for %v: status = %d, want %d",
|
t.Errorf("for %v: status = %d, want %d",
|
||||||
route, err, rec.Code, http.StatusServiceUnavailable)
|
err, rec.Code, http.StatusServiceUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
t.Errorf("%s for %v: body = %q, want the server busy error",
|
t.Errorf("for %v: body = %q, want the server busy error",
|
||||||
route, err, rec.Body.String())
|
err, rec.Body.String())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessingTimedOutAnswers503 checks that respondImageError and
|
// TestProcessingTimedOutAnswers503 checks that respondImageError, which
|
||||||
// handleImageError, which answer the errors of the two image routes, answer
|
// answers the errors of both image routes, answers 503 with the server busy
|
||||||
// 503 with the server busy error when the image service reports that
|
// error when the image service reports that processing stopped at the
|
||||||
// processing stopped at the deadline of the request doing it, wrapped as the
|
// deadline of the request doing it, wrapped as the service wraps it. A request
|
||||||
// service wraps it. A request that waited for that processing gets this error
|
// that waited for that processing gets this error while its own deadline may
|
||||||
// while its own deadline may have time left. The request's source only goes
|
// have time left. The request's source only goes into a log line, so it is
|
||||||
// into a log line, so it is left empty.
|
// left empty.
|
||||||
func TestProcessingTimedOutAnswers503(t *testing.T) {
|
func TestProcessingTimedOutAnswers503(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -63,25 +60,15 @@ func TestProcessingTimedOutAnswers503(t *testing.T) {
|
|||||||
err := fmt.Errorf("%w: failed to encode: %w",
|
err := fmt.Errorf("%w: failed to encode: %w",
|
||||||
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
|
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
|
||||||
|
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
|
h.respondImageError(rec, &imagerequest.Request{}, err)
|
||||||
|
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
|
||||||
encrypted := httptest.NewRecorder()
|
|
||||||
h.handleImageError(encrypted, err)
|
|
||||||
|
|
||||||
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 {
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("%s: status = %d, want %d",
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||||
name, rec.Code, http.StatusServiceUnavailable)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
t.Errorf("%s: body = %q, want the server busy error",
|
t.Errorf("body = %q, want the server busy error", rec.Body.String())
|
||||||
name, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,21 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
|
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
|
||||||
// 415 with a clear error when the upstream answers with a content type pixa
|
// answers the errors of both image routes, answers 415 with a clear error when
|
||||||
// does not accept, such as SVG, and when orig is asked of a source in a
|
// the upstream answers with a content type pixa does not accept, such as SVG,
|
||||||
// format the processor cannot name, wrapped as the service wraps them.
|
// 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) {
|
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"}
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.svg"},
|
||||||
|
}
|
||||||
|
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
||||||
@@ -29,26 +32,18 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
|||||||
fmt.Errorf("image processing failed: %w: unknown",
|
fmt.Errorf("image processing failed: %w: unknown",
|
||||||
imageprocessor.ErrUnsupportedInputFormat),
|
imageprocessor.ErrUnsupportedInputFormat),
|
||||||
} {
|
} {
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, req, err)
|
h.respondImageError(rec, req, err)
|
||||||
|
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
||||||
encrypted := httptest.NewRecorder()
|
|
||||||
h.handleImageError(encrypted, err)
|
|
||||||
|
|
||||||
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 {
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
t.Errorf("%s for %v: status = %d, want %d",
|
t.Errorf("for %v: status = %d, want %d",
|
||||||
route, err, rec.Code, http.StatusUnsupportedMediaType)
|
err, rec.Code, http.StatusUnsupportedMediaType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||||
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
|
t.Errorf("for %v: body = %q, want the unsupported source format error",
|
||||||
route, err, rec.Body.String())
|
err, rec.Body.String())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
||||||
@@ -70,12 +71,15 @@ func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
|||||||
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
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
|
// 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.
|
// blocking it can only come from the operator-supplied list.
|
||||||
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")},
|
||||||
|
}))
|
||||||
transport := transportOf(t, New(cfg))
|
|
||||||
|
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"198.51.100.5:80", // operator-supplied range
|
"198.51.100.5:80", // operator-supplied range
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
||||||
@@ -13,7 +14,12 @@ func TestNewUsesCheckedDialerWithoutDialContext(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
transport := transportOf(t, New(DefaultConfig()))
|
transport := transportOf(t, New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}))
|
||||||
|
|
||||||
addr := srv.Listener.Addr().String()
|
addr := srv.Listener.Addr().String()
|
||||||
|
|
||||||
@@ -32,21 +38,25 @@ func TestDialContextReplacesOnlyTheDialer(t *testing.T) {
|
|||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
f := New(&Config{
|
||||||
cfg.AllowHTTP = true
|
Timeout: 30 * time.Second,
|
||||||
cfg.DialContext = dialer.dialContext
|
MaxResponseSize: 50 << 20,
|
||||||
f := New(cfg)
|
AllowHTTP: true,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
DialContext: dialer.dialContext,
|
||||||
|
})
|
||||||
|
|
||||||
if body := fetchBody(t, f, "/image"); body != imagePayload {
|
if body := fetchBody(t, f, "/image"); body != imagePayload {
|
||||||
t.Errorf("body = %q, want %q", body, imagePayload)
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
|
_, err := f.Fetch(testContext(t), imageSourceOn("127.0.0.1"))
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
|
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
_, err = f.Fetch(testContext(t), upstreamSource("/redirect/private"))
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
|
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
||||||
@@ -29,9 +31,9 @@ const imagePayload = "fake-jpeg-bytes"
|
|||||||
// would mean SSRF protection let a forbidden target reach the transport.
|
// would mean SSRF protection let a forbidden target reach the transport.
|
||||||
var errUnexpectedDial = errors.New("unexpected dial target")
|
var errUnexpectedDial = errors.New("unexpected dial target")
|
||||||
|
|
||||||
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
// upstreamSource returns the source at path on the fake public host.
|
||||||
func upstreamURL(path string) string {
|
func upstreamSource(path string) imagerequest.Source {
|
||||||
return "http://" + testPublicHost + path
|
return imagerequest.Source{Host: testPublicHost, Path: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordingDialer records every address the transport asks it to dial and
|
// recordingDialer records every address the transport asks it to dial and
|
||||||
@@ -120,7 +122,12 @@ func newServerFetcher(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = DefaultConfig()
|
cfg = &Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.AllowHTTP = true
|
cfg.AllowHTTP = true
|
||||||
@@ -153,7 +160,7 @@ func testContext(t *testing.T) context.Context {
|
|||||||
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
res, err := f.Fetch(testContext(t), upstreamSource(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch(%s) error = %v", path, err)
|
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||||
}
|
}
|
||||||
@@ -165,7 +172,7 @@ func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
|||||||
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
res, err := f.Fetch(testContext(t), upstreamSource(path))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = res.Content.Close()
|
_ = res.Content.Close()
|
||||||
|
|
||||||
@@ -217,7 +224,7 @@ func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
|
|||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
f, dialer := newServerFetcher(t, srv, nil)
|
f, dialer := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
_, err := f.Fetch(testContext(t), upstreamSource("/redirect/private"))
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
@@ -280,8 +287,12 @@ func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxResponseSize = 8
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 8,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -310,8 +321,12 @@ func fetchWithMaxResponseSize(
|
|||||||
srv := httptest.NewServer(handler)
|
srv := httptest.NewServer(handler)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxResponseSize = maxResponseSize
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: maxResponseSize,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -436,8 +451,12 @@ func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnectionsPerHost = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 1,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -466,8 +485,12 @@ func assertSlotReleasedByClose(
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnectionsPerHost = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 1,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -524,7 +547,7 @@ func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
|
// The dial-time re-resolution in dialSSRFSafe is what closes the DNS
|
||||||
// rebinding window: even if validateURL saw a public answer earlier, the
|
// 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
|
// dialer independently re-checks the address it is about to connect to. A full
|
||||||
// rebinding simulation (a resolver returning public, then private) would mean
|
// rebinding simulation (a resolver returning public, then private) would mean
|
||||||
@@ -544,9 +567,9 @@ func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
|
|||||||
t.Run(addr, func(t *testing.T) {
|
t.Run(addr, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
|
_, err := dialSSRFSafe(context.Background(), "tcp", addr, nil)
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
|
t.Errorf("dialSSRFSafe(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -560,7 +583,7 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
|
_, err := dialSSRFSafe(ctx, "tcp", testPublicHost+":80", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected a dial error for an unreachable public target")
|
t.Fatal("expected a dial error for an unreachable public target")
|
||||||
}
|
}
|
||||||
@@ -569,3 +592,53 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
|||||||
t.Errorf("public target was SSRF-blocked: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,20 +20,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetcher configuration constants.
|
// Fetcher configuration constants.
|
||||||
const (
|
const (
|
||||||
DefaultFetchTimeout = 30 * time.Second
|
|
||||||
DefaultMaxResponseSize = 50 << 20 // 50MB
|
|
||||||
DefaultTLSTimeout = 10 * time.Second
|
DefaultTLSTimeout = 10 * time.Second
|
||||||
DefaultMaxIdleConns = 100
|
DefaultMaxIdleConns = 100
|
||||||
DefaultIdleConnTimeout = 90 * time.Second
|
DefaultIdleConnTimeout = 90 * time.Second
|
||||||
DefaultMaxRedirects = 10
|
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
|
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
||||||
// MaxConnections fetches are already in progress.
|
// MaxConnections fetches are already in progress.
|
||||||
const ConnectionWaitTimeout = 10 * time.Second
|
const ConnectionWaitTimeout = 10 * time.Second
|
||||||
@@ -92,8 +92,8 @@ var (
|
|||||||
|
|
||||||
// Fetcher retrieves content from upstream origins.
|
// Fetcher retrieves content from upstream origins.
|
||||||
type Fetcher interface {
|
type Fetcher interface {
|
||||||
// Fetch retrieves content from the given URL.
|
// Fetch retrieves the content of the source image.
|
||||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
Fetch(ctx context.Context, source imagerequest.Source) (*FetchResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchResult contains the result of fetching from upstream.
|
// FetchResult contains the result of fetching from upstream.
|
||||||
@@ -120,17 +120,28 @@ type FetchResult struct {
|
|||||||
TLSCipherSuite string
|
TLSCipherSuite string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds configuration for the upstream fetcher.
|
// 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.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Timeout for upstream requests.
|
// Timeout for upstream requests.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// MaxResponseSize is the maximum allowed response body size.
|
// MaxResponseSize is the maximum allowed response body size.
|
||||||
MaxResponseSize int64
|
MaxResponseSize int64
|
||||||
// UserAgent to send to upstream servers.
|
// AllowHTTP makes the fetcher fetch sources over http instead of https,
|
||||||
UserAgent string
|
// and follow redirects to http (for testing only).
|
||||||
// AllowedContentTypes is an allow list of MIME types to accept.
|
|
||||||
AllowedContentTypes []string
|
|
||||||
// AllowHTTP allows non-TLS connections (for testing only).
|
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
MaxConnectionsPerHost int
|
||||||
@@ -147,26 +158,6 @@ type Config struct {
|
|||||||
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
|
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
|
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
||||||
// per host and for all hosts together.
|
// per host and for all hosts together.
|
||||||
type HTTPFetcher struct {
|
type HTTPFetcher struct {
|
||||||
@@ -194,10 +185,6 @@ type hostSemaphore struct {
|
|||||||
|
|
||||||
// New creates a new HTTPFetcher with SSRF protection.
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
func New(config *Config) *HTTPFetcher {
|
func New(config *Config) *HTTPFetcher {
|
||||||
if config == nil {
|
|
||||||
config = DefaultConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unless config.DialContext replaces it, the transport connects with
|
// Unless config.DialContext replaces it, the transport connects with
|
||||||
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
||||||
// (closing the DNS-rebinding window) against both the built-in ranges
|
// (closing the DNS-rebinding window) against both the built-in ranges
|
||||||
@@ -226,7 +213,7 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the redirect target
|
// Validate the redirect target
|
||||||
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
err := validateURL(req.Context(), req.URL, config.AllowHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("redirect blocked: %w", err)
|
return fmt.Errorf("redirect blocked: %w", err)
|
||||||
}
|
}
|
||||||
@@ -244,18 +231,22 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves content from the given URL with SSRF protection. When
|
// Fetch retrieves the source image with SSRF protection, over https, or over
|
||||||
// MaxConnections fetches are already in progress, it waits up to
|
// http when AllowHTTP is set. When MaxConnections fetches are already in
|
||||||
// ConnectionWaitTimeout for one to finish, then fails with
|
// progress, it waits up to ConnectionWaitTimeout for one to finish, then
|
||||||
// ErrTooManyConnections.
|
// fails with ErrTooManyConnections.
|
||||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
func (f *HTTPFetcher) Fetch(
|
||||||
|
ctx context.Context, source imagerequest.Source,
|
||||||
|
) (*FetchResult, error) {
|
||||||
|
sourceURL := source.URL(f.config.AllowHTTP)
|
||||||
|
|
||||||
// Validate URL before making request
|
// Validate URL before making request
|
||||||
err := validateURL(ctx, url, f.config.AllowHTTP)
|
err := validateURL(ctx, sourceURL, f.config.AllowHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := f.acquireConnection(ctx, extractHost(url))
|
release, err := f.acquireConnection(ctx, source.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -269,19 +260,14 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
parsedURL, err := neturl.Parse(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &http.Request{
|
req := &http.Request{
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
URL: parsedURL,
|
URL: sourceURL,
|
||||||
Header: make(http.Header),
|
Header: make(http.Header),
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", f.config.UserAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
req.Header.Set("Accept", strings.Join(allowedContentTypes(), ", "))
|
||||||
|
|
||||||
// The ID of the request this fetch serves, so the fetch can be found in
|
// The ID of the request this fetch serves, so the fetch can be found in
|
||||||
// the upstream host's logs
|
// the upstream host's logs
|
||||||
@@ -460,7 +446,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|||||||
// Extract the MIME type without parameters
|
// Extract the MIME type without parameters
|
||||||
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
|
|
||||||
for _, allowed := range f.config.AllowedContentTypes {
|
for _, allowed := range allowedContentTypes() {
|
||||||
if strings.EqualFold(mediaType, allowed) {
|
if strings.EqualFold(mediaType, allowed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -470,19 +456,14 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||||
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) error {
|
||||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
if !allowHTTP && fetchURL.Scheme != "https" {
|
||||||
return ErrUnsupportedScheme
|
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
|
// Hostname drops any port, and the brackets of an IPv6 address whether
|
||||||
// or not a port follows them
|
// or not a port follows them
|
||||||
host := parsedURL.Hostname()
|
host := fetchURL.Hostname()
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return ErrInvalidHost
|
return ErrInvalidHost
|
||||||
}
|
}
|
||||||
@@ -508,25 +489,6 @@ func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
|||||||
return nil
|
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.
|
// isLocalhost checks if the host is localhost.
|
||||||
func isLocalhost(host string) bool {
|
func isLocalhost(host string) bool {
|
||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
@@ -639,13 +601,6 @@ func ipv4CompatibleToIPv4(ip net.IP) net.IP {
|
|||||||
return 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
|
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
||||||
// internal range or operator-supplied blocked prefix, closing the
|
// internal range or operator-supplied blocked prefix, closing the
|
||||||
// DNS-rebinding window at connect time.
|
// DNS-rebinding window at connect time.
|
||||||
|
|||||||
@@ -5,62 +5,26 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testHost is the hostname used by mock fetch tests.
|
// testHost is the hostname used by mock fetch tests.
|
||||||
const testHost = "example.com"
|
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) {
|
func TestIsAllowedContentType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := New(DefaultConfig())
|
f := New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
contentType string
|
contentType string
|
||||||
@@ -88,33 +52,6 @@ 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) {
|
func TestIsLocalhost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -227,7 +164,7 @@ func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
|
|||||||
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := validateURL(t.Context(), "http://example.com/path", false)
|
err := validateURL(t.Context(), &url.URL{Scheme: "http", Host: testHost}, false)
|
||||||
if !errors.Is(err, ErrUnsupportedScheme) {
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||||
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +174,8 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
||||||
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
err := validateURL(t.Context(),
|
||||||
|
&url.URL{Scheme: "http", Host: "nonexistent.invalid"}, true)
|
||||||
// We expect a host resolution error, not ErrUnsupportedScheme.
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||||
if errors.Is(err, ErrUnsupportedScheme) {
|
if errors.Is(err, ErrUnsupportedScheme) {
|
||||||
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||||
@@ -247,7 +185,7 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
|||||||
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := validateURL(t.Context(), "https://localhost/path", false)
|
err := validateURL(t.Context(), &url.URL{Scheme: "https", Host: "localhost"}, false)
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +194,7 @@ func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
|||||||
func TestValidateURL_EmptyHost(t *testing.T) {
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := validateURL(t.Context(), "https:///path", false)
|
err := validateURL(t.Context(), &url.URL{Scheme: "https"}, false)
|
||||||
if !errors.Is(err, ErrInvalidHost) {
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||||
}
|
}
|
||||||
@@ -271,7 +209,8 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
|||||||
|
|
||||||
m := NewMock(mockFS)
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
result, err := m.Fetch(context.Background(),
|
||||||
|
imagerequest.Source{Host: testHost, Path: "/images/photo.jpg"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() error = %v", err)
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -302,7 +241,8 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
|||||||
mockFS := fstest.MapFS{}
|
mockFS := fstest.MapFS{}
|
||||||
m := NewMock(mockFS)
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
_, err := m.Fetch(context.Background(),
|
||||||
|
imagerequest.Source{Host: testHost, Path: "/missing.jpg"})
|
||||||
if !errors.Is(err, ErrUpstreamError) {
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
}
|
}
|
||||||
@@ -319,7 +259,7 @@ func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
_, err := m.Fetch(ctx, imagerequest.Source{Host: testHost, Path: "/photo.jpg"})
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
package httpfetcher
|
package httpfetcher
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
||||||
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
|
// of type image/jxl, and that the mock fetcher serves a .jxl file as
|
||||||
// as image/jxl.
|
// image/jxl.
|
||||||
func TestJPEGXLContentType(t *testing.T) {
|
func TestJPEGXLContentType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const jxlType = "image/jxl"
|
const jxlType = "image/jxl"
|
||||||
|
|
||||||
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
|
f := New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !f.isAllowedContentType(jxlType) {
|
||||||
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
|
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,20 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
// 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
|
||||||
// port. Each port is a different host to the per-host limit, while the test
|
// port. Each port is a different host to the per-host limit, while the test
|
||||||
// dialer sends every port to the one test server.
|
// dialer sends every port to the one test server.
|
||||||
func imageURLOnPort(port int) string {
|
func imageSourceOnPort(port int) imagerequest.Source {
|
||||||
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
return imageSourceOn(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
|
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
||||||
@@ -37,18 +35,22 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 2
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 2,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
first, err := f.Fetch(testContext(t), imageSourceOnPort(81))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
second, err := f.Fetch(testContext(t), imageSourceOnPort(82))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second Fetch() error = %v", err)
|
t.Fatalf("second Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
|
_, err = f.Fetch(testContext(t), imageSourceOnPort(83))
|
||||||
if !errors.Is(err, ErrTooManyConnections) {
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
t.Fatalf("close first body: %v", err)
|
t.Fatalf("close first body: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
|
third, err := f.Fetch(testContext(t), imageSourceOnPort(83))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -94,12 +96,16 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 1,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
first, err := f.Fetch(testContext(t), imageSourceOnPort(81))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -109,7 +115,7 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(82))
|
_, err = f.Fetch(ctx, imageSourceOnPort(82))
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
||||||
}
|
}
|
||||||
@@ -134,7 +140,7 @@ func TestFetchRemovesIdleHostSemaphores(t *testing.T) {
|
|||||||
|
|
||||||
for i := range 100 {
|
for i := range 100 {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
res, err := f.Fetch(ctx, imageURLOnPort(1+i%50))
|
res, err := f.Fetch(ctx, imageSourceOnPort(1+i%50))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Fetch() error = %v", err)
|
t.Errorf("Fetch() error = %v", err)
|
||||||
|
|
||||||
@@ -163,19 +169,22 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 1
|
Timeout: 30 * time.Second,
|
||||||
cfg.MaxConnectionsPerHost = 1
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 1,
|
||||||
|
MaxConnections: 1,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
open, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
open, err := f.Fetch(testContext(t), imageSourceOnPort(81))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), imageURLOnPort(82))
|
_, err = f.Fetch(testContext(t), imageSourceOnPort(82))
|
||||||
if !errors.Is(err, ErrTooManyConnections) {
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
t.Fatalf("Fetch() from another host: error = %v, "+
|
t.Fatalf("Fetch() from another host: error = %v, "+
|
||||||
"want ErrTooManyConnections", err)
|
"want ErrTooManyConnections", err)
|
||||||
@@ -184,7 +193,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(81))
|
_, err = f.Fetch(ctx, imageSourceOnPort(81))
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
t.Fatalf("Fetch() from the busy host: error = %v, "+
|
t.Fatalf("Fetch() from the busy host: error = %v, "+
|
||||||
"want context.DeadlineExceeded", err)
|
"want context.DeadlineExceeded", err)
|
||||||
@@ -196,7 +205,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
|
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(83))
|
_, err = f.Fetch(ctx, imageSourceOnPort(83))
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
|
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
|
||||||
"want context.DeadlineExceeded", err)
|
"want context.DeadlineExceeded", err)
|
||||||
@@ -220,14 +229,14 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
url string
|
source imagerequest.Source
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
{"upstream answers 500", upstreamSource("/status/500"), ErrUpstreamError},
|
||||||
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
|
{"upstream sends HTML", upstreamSource("/html"), ErrInvalidContentType},
|
||||||
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
||||||
// dialer refuses every host but testPublicHost.
|
// dialer refuses every host but testPublicHost.
|
||||||
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
{"connecting fails", imageSourceOn("198.51.100.7"), errUnexpectedDial},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -236,13 +245,17 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 1,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), tc.url)
|
_, err := f.Fetch(testContext(t), tc.source)
|
||||||
if !errors.Is(err, tc.want) {
|
if !errors.Is(err, tc.want) {
|
||||||
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
|
||||||
|
|
||||||
// errEmptyURLPath is returned when a mock URL has no usable path.
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
var errEmptyURLPath = errors.New("empty URL path")
|
)
|
||||||
|
|
||||||
// MockFetcher implements Fetcher using an embedded filesystem.
|
// MockFetcher implements Fetcher using an embedded filesystem.
|
||||||
// Files are organized as: hostname/path/to/file.ext
|
// Files are organized as: hostname/path/to/file.ext
|
||||||
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
// The source with host example.com and path /images/photo.jpg maps to
|
||||||
|
// example.com/images/photo.jpg; the source query is ignored.
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
@@ -24,8 +24,10 @@ func NewMock(fsys fs.FS) *MockFetcher {
|
|||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves content from the mock filesystem.
|
// Fetch retrieves the source's content from the mock filesystem.
|
||||||
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
func (m *MockFetcher) Fetch(
|
||||||
|
ctx context.Context, source imagerequest.Source,
|
||||||
|
) (*FetchResult, error) {
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -33,11 +35,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL to get filesystem path
|
path := source.Host + source.Path
|
||||||
path, err := urlToFSPath(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the file
|
// Open the file
|
||||||
f, err := m.fs.Open(path)
|
f, err := m.fs.Open(path)
|
||||||
@@ -68,32 +66,6 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}, nil
|
}, 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.
|
// detectContentTypeFromPath returns the MIME type based on file extension.
|
||||||
func detectContentTypeFromPath(path string) string {
|
func detectContentTypeFromPath(path string) string {
|
||||||
path = strings.ToLower(path)
|
path = strings.ToLower(path)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestFetchSendsRequestID(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
|
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
|
||||||
|
|
||||||
res, err := f.Fetch(ctx, upstreamURL("/image"))
|
res, err := f.Fetch(ctx, upstreamSource("/image"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() error = %v", err)
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
|
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
|
||||||
@@ -17,13 +19,17 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"width at the maximum", 4096, 1, Size{Height: 2}, nil},
|
{"width at the maximum", 4096, 1,
|
||||||
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge},
|
imagerequest.Size{Height: 2}, nil},
|
||||||
{"height at the maximum", 1, 4096, Size{Width: 2}, nil},
|
{"width above the maximum", 4096, 1,
|
||||||
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge},
|
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},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -32,9 +38,16 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
@@ -64,18 +77,21 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantWidth, wantHeight int
|
wantWidth, wantHeight int
|
||||||
}{
|
}{
|
||||||
{"8192x1 at 100x0", 8192, 1, Size{Width: 100}, 100, 1},
|
{"8192x1 at 100x0", 8192, 1, imagerequest.Size{Width: 100}, 100, 1},
|
||||||
{"8192x1 at 1300x0", 8192, 1, Size{Width: 1300}, 1300, 1},
|
{"8192x1 at 1300x0", 8192, 1, imagerequest.Size{Width: 1300}, 1300, 1},
|
||||||
{"8192x2 at 100x0", 8192, 2, Size{Width: 100}, 100, 1},
|
{"8192x2 at 100x0", 8192, 2, imagerequest.Size{Width: 100}, 100, 1},
|
||||||
{"1x8192 at 0x100", 1, 8192, Size{Height: 100}, 1, 100},
|
{"1x8192 at 0x100", 1, 8192, imagerequest.Size{Height: 100}, 1, 100},
|
||||||
{"1x8192 at 0x1300", 1, 8192, Size{Height: 1300}, 1, 1300},
|
{"1x8192 at 0x1300", 1, 8192, imagerequest.Size{Height: 1300}, 1, 1300},
|
||||||
{"2x8192 at 0x100", 2, 8192, Size{Height: 100}, 1, 100},
|
{"2x8192 at 0x100", 2, 8192, imagerequest.Size{Height: 100}, 1, 100},
|
||||||
}
|
}
|
||||||
|
|
||||||
fits := []FitMode{FitCover, FitContain, FitFill, FitInside, FitOutside}
|
fits := []imagerequest.Fit{
|
||||||
|
imagerequest.FitCover, imagerequest.FitContain, imagerequest.FitFill,
|
||||||
|
imagerequest.FitInside, imagerequest.FitOutside,
|
||||||
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
for _, fit := range fits {
|
for _, fit := range fits {
|
||||||
@@ -91,9 +107,16 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: fit})
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: fit,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,14 +126,14 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
|
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
|
||||||
if fit == FitOutside {
|
if fit == imagerequest.FitOutside {
|
||||||
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
|
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
|
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
|
||||||
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
|
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
|
||||||
FitOutside)
|
imagerequest.FitOutside)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ func checkProcessStopsAtDeadline(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
result, err := proc.Process(ctx, input, smallJPEGRequest())
|
result, err := proc.Process(ctx, input, smallJPEGTransform())
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made a %dx%d image after its deadline passed",
|
t.Logf("Process() made a %dx%d image after its deadline passed",
|
||||||
result.Width, result.Height)
|
result.Width, result.Height)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
|
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
|
||||||
@@ -12,8 +14,12 @@ import (
|
|||||||
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := New(Params{}).Process(
|
_, err := New(Params{
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
|
||||||
|
&imagerequest.Transform{},
|
||||||
)
|
)
|
||||||
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
||||||
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// processedSize runs input through Process and returns the output's size in
|
// processedSize runs input through Process and returns the output's size in
|
||||||
// bytes.
|
// bytes.
|
||||||
func processedSize(t *testing.T, input []byte, req *Request) int64 {
|
func processedSize(
|
||||||
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(), bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,7 +74,8 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
|
size := processedSize(t, encodePNG(t, flat),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
const rawBytes = width * height * 3
|
const rawBytes = width * height * 3
|
||||||
if size > rawBytes/10 {
|
if size > rawBytes/10 {
|
||||||
@@ -77,27 +84,28 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestImageProcessor_WebPAtDefaultEffort verifies that WebP is saved at
|
// TestImageProcessor_WebPAtEffort2 verifies that WebP is saved at effort 2,
|
||||||
// libvips' default effort, 4: the output is the size of the same image saved
|
// not libvips' default of 4: the output is the size of the same image saved
|
||||||
// at that effort.
|
// at effort 2.
|
||||||
func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) {
|
func TestImageProcessor_WebPAtEffort2(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
|
size := processedSize(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
StripMetadata: true,
|
StripMetadata: true,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
ReductionEffort: 4,
|
ReductionEffort: 2,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExportWebp() error = %v", err)
|
t.Fatalf("ExportWebp() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if size != int64(len(want)) {
|
if size != int64(len(want)) {
|
||||||
t.Errorf("WebP output is %d bytes, want %d, the size at effort 4",
|
t.Errorf("WebP output is %d bytes, want %d, the size at effort 2",
|
||||||
size, len(want))
|
size, len(want))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +134,8 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
|||||||
|
|
||||||
input := encodePNG(t, source)
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
|
size := processedSize(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
StripMetadata: true,
|
StripMetadata: true,
|
||||||
@@ -143,3 +152,30 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
|||||||
"and 8 bits", size, len(want))
|
"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,6 +7,8 @@ import (
|
|||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
|
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
|
||||||
@@ -20,12 +22,12 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
}{
|
}{
|
||||||
{"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}},
|
{"8192x1 at 8192x8192", 8192, 1, imagerequest.Size{Width: 8192, Height: 8192}},
|
||||||
{"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}},
|
{"8192x1 at 1x8192", 8192, 1, imagerequest.Size{Width: 1, Height: 8192}},
|
||||||
{"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}},
|
{"1x8192 at 8192x8192", 1, 8192, imagerequest.Size{Width: 8192, Height: 8192}},
|
||||||
{"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}},
|
{"1x8192 at 8192x1", 1, 8192, imagerequest.Size{Width: 8192, Height: 1}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -34,9 +36,16 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -100,12 +109,15 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
|
|||||||
|
|
||||||
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{
|
&imagerequest.Transform{
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -208,14 +220,14 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
side, squareSide int
|
side, squareSide int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
}{
|
}{
|
||||||
{"16x16 at 100x60", 16, 8, Size{Width: 100, Height: 60}},
|
{"16x16 at 100x60", 16, 8, imagerequest.Size{Width: 100, Height: 60}},
|
||||||
{"16x16 at 60x100", 16, 8, Size{Width: 60, Height: 100}},
|
{"16x16 at 60x100", 16, 8, imagerequest.Size{Width: 60, Height: 100}},
|
||||||
{"10x10 at 100x35", 10, 2, Size{Width: 100, Height: 35}},
|
{"10x10 at 100x35", 10, 2, imagerequest.Size{Width: 100, Height: 35}},
|
||||||
{"10x10 at 35x100", 10, 2, Size{Width: 35, Height: 100}},
|
{"10x10 at 35x100", 10, 2, imagerequest.Size{Width: 35, Height: 100}},
|
||||||
{"64x64 at 300x100", 64, 16, Size{Width: 300, Height: 100}},
|
{"64x64 at 300x100", 64, 16, imagerequest.Size{Width: 300, Height: 100}},
|
||||||
{"64x64 at 100x300", 64, 16, Size{Width: 100, Height: 300}},
|
{"64x64 at 100x300", 64, 16, imagerequest.Size{Width: 100, Height: 300}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -224,9 +236,16 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|||||||
|
|
||||||
input := createSquarePNG(t, tt.side, tt.squareSide)
|
input := createSquarePNG(t, tt.side, tt.squareSide)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{Size: tt.size, Format: FormatPNG, FitMode: FitCover})
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatPNG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
|
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
|
||||||
@@ -17,15 +19,20 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
// wantWidth and wantHeight are the output's, 0 for a refusal
|
// wantWidth and wantHeight are the output's, 0 for a refusal
|
||||||
wantWidth, wantHeight int
|
wantWidth, wantHeight int
|
||||||
}{
|
}{
|
||||||
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0},
|
{"width above the maximum", 3000, 600,
|
||||||
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0},
|
imagerequest.Size{Width: 1, Height: 8192}, 0, 0},
|
||||||
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048},
|
{"height above the maximum", 600, 3000,
|
||||||
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192},
|
imagerequest.Size{Width: 8192, Height: 1}, 0, 0},
|
||||||
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200},
|
{"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},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -34,9 +41,16 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitOutside,
|
||||||
|
})
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
}
|
}
|
||||||
@@ -71,11 +85,11 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantWidth, wantHeight int
|
wantWidth, wantHeight int
|
||||||
}{
|
}{
|
||||||
{"49x8192 at 0x100", 49, 8192, Size{Height: 100}, 1, 167},
|
{"49x8192 at 0x100", 49, 8192, imagerequest.Size{Height: 100}, 1, 167},
|
||||||
{"49x49 at 1x1", 49, 49, Size{Width: 1, Height: 1}, 1, 1},
|
{"49x49 at 1x1", 49, 49, imagerequest.Size{Width: 1, Height: 1}, 1, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -84,9 +98,16 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input),
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitOutside,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
@@ -54,49 +54,6 @@ func CheckJPEGXLSupport() error {
|
|||||||
return nil
|
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.
|
// Result contains the output of image processing.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
// Content is the processed image data.
|
// Content is the processed image data.
|
||||||
@@ -121,10 +78,6 @@ type Result struct {
|
|||||||
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
||||||
const MaxInputDimension = 8192
|
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.
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
||||||
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||||
|
|
||||||
@@ -133,8 +86,8 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
|||||||
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||||
|
|
||||||
// MaxOutputDimension is the maximum width or height of an output image, and
|
// MaxOutputDimension is the maximum width or height of an output image, and
|
||||||
// so of a requested size (imgcache.MaxDimension).
|
// so of a requested size (imagerequest.MaxDimension).
|
||||||
const MaxOutputDimension = 8192
|
const MaxOutputDimension = imagerequest.MaxDimension
|
||||||
|
|
||||||
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
||||||
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
||||||
@@ -142,6 +95,19 @@ const MaxOutputDimension = 8192
|
|||||||
// requested size.
|
// requested size.
|
||||||
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
|
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
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
||||||
// not supported.
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
@@ -171,50 +137,38 @@ type ImageProcessor struct {
|
|||||||
processingWaitTimeout time.Duration
|
processingWaitTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Params holds configuration for creating an ImageProcessor.
|
// Params holds configuration for creating an ImageProcessor. Both fields
|
||||||
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
// must be at least 1.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// MaxInputBytes is the maximum allowed input size in bytes.
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
// If <= 0, DefaultMaxInputBytes is used.
|
|
||||||
MaxInputBytes int64
|
MaxInputBytes int64
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
// If <= 0, config.DefaultMaxConcurrentProcessing() is used.
|
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new image processor with the given parameters.
|
// New creates a new image processor with the given parameters.
|
||||||
// A zero-value Params{} uses sensible defaults.
|
|
||||||
func New(params Params) *ImageProcessor {
|
func New(params Params) *ImageProcessor {
|
||||||
initVips()
|
initVips()
|
||||||
|
|
||||||
maxInputBytes := params.MaxInputBytes
|
|
||||||
if maxInputBytes <= 0 {
|
|
||||||
maxInputBytes = DefaultMaxInputBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
|
||||||
if maxConcurrentProcessing <= 0 {
|
|
||||||
maxConcurrentProcessing = config.DefaultMaxConcurrentProcessing()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
maxInputBytes: maxInputBytes,
|
maxInputBytes: params.MaxInputBytes,
|
||||||
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
processingSemaphore: make(chan struct{}, params.MaxConcurrentProcessing),
|
||||||
processingWaitTimeout: ProcessingWaitTimeout,
|
processingWaitTimeout: ProcessingWaitTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request. When
|
// Process transforms an image according to transform. When
|
||||||
// MaxConcurrentProcessing images are already being processed, it waits up
|
// MaxConcurrentProcessing images are already being processed, it waits up
|
||||||
// to ProcessingWaitTimeout for one to finish, then fails with
|
// to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
|
// 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
|
// 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
|
// no way to stop libvips once an encode has begun, so an encode keeps its
|
||||||
// slot until it finishes.
|
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
|
||||||
|
// refused with ErrAVIFOutputTooLarge before anything is decoded.
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *Request,
|
transform *imagerequest.Transform,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
release, err := p.acquireSlot(ctx)
|
release, err := p.acquireSlot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -223,18 +177,9 @@ func (p *ImageProcessor) Process(
|
|||||||
|
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Read input with a size limit to prevent unbounded memory consumption.
|
data, err := p.readInput(input)
|
||||||
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read input: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
if int64(len(data)) > p.maxInputBytes {
|
|
||||||
return nil, ErrInputDataTooLarge
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// govips picks the libvips loader from the input's first bytes with
|
// govips picks the libvips loader from the input's first bytes with
|
||||||
@@ -272,27 +217,36 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine target dimensions; too large ones are refused before resizing
|
// Determine target dimensions; too large ones are refused before resizing
|
||||||
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize if needed
|
// Resize if needed
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
|
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
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
|
// Encode to target format
|
||||||
output, err := p.encode(ctx, img, outputFormat, req.Quality)
|
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
}
|
}
|
||||||
@@ -315,7 +269,9 @@ func (p *ImageProcessor) Process(
|
|||||||
// rounded down but kept at 1 or more, as for a thin image it would round
|
// 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,
|
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
|
||||||
// which is ErrOutputTooLarge.
|
// which is ErrOutputTooLarge.
|
||||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
func targetDimensions(
|
||||||
|
size imagerequest.Size, origWidth, origHeight int,
|
||||||
|
) (int, int, error) {
|
||||||
width, height := size.Width, size.Height
|
width, height := size.Width, size.Height
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -348,46 +304,22 @@ const (
|
|||||||
mimeJXL = "image/jxl"
|
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.
|
// FormatToMIME converts a Format to its MIME type string.
|
||||||
func FormatToMIME(format Format) string {
|
func FormatToMIME(format imagerequest.Format) string {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case imagerequest.FormatJPEG:
|
||||||
return mimeJPEG
|
return mimeJPEG
|
||||||
case FormatPNG:
|
case imagerequest.FormatPNG:
|
||||||
return mimePNG
|
return mimePNG
|
||||||
case FormatWebP:
|
case imagerequest.FormatWebP:
|
||||||
return mimeWebP
|
return mimeWebP
|
||||||
case FormatGIF:
|
case imagerequest.FormatGIF:
|
||||||
return mimeGIF
|
return mimeGIF
|
||||||
case FormatAVIF:
|
case imagerequest.FormatAVIF:
|
||||||
return mimeAVIF
|
return mimeAVIF
|
||||||
case FormatJXL:
|
case imagerequest.FormatJXL:
|
||||||
return mimeJXL
|
return mimeJXL
|
||||||
case FormatOriginal:
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
@@ -445,6 +377,23 @@ func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
|||||||
return release, nil
|
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.
|
// detectFormat returns the format string for a govips image type.
|
||||||
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||||
switch imageType {
|
switch imageType {
|
||||||
@@ -457,9 +406,9 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
|||||||
case vips.ImageTypeWEBP:
|
case vips.ImageTypeWEBP:
|
||||||
return "webp"
|
return "webp"
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
return string(FormatAVIF)
|
return string(imagerequest.FormatAVIF)
|
||||||
case vips.ImageTypeJXL:
|
case vips.ImageTypeJXL:
|
||||||
return string(FormatJXL)
|
return string(imagerequest.FormatJXL)
|
||||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
vips.ImageTypeJP2K:
|
vips.ImageTypeJP2K:
|
||||||
@@ -469,72 +418,94 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize resizes the image according to the fit mode. With FitOutside one
|
// outputDimensions returns the dimensions resize gives an image of imgWidth x
|
||||||
// side can come out larger than requested; when it would be above
|
// imgHeight for the target dimensions and fit mode, without resizing it, and
|
||||||
// MaxOutputDimension, resize returns ErrOutputTooLarge without resizing.
|
// refuses an output too large for format. With FitOutside one side can come
|
||||||
func (p *ImageProcessor) resize(
|
// out larger than the target; when it would be above MaxOutputDimension,
|
||||||
img *vips.ImageRef, width, height int, fit FitMode,
|
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
|
||||||
) error {
|
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
|
||||||
switch fit {
|
func outputDimensions(
|
||||||
case FitCover, "":
|
imgWidth, imgHeight, width, height int,
|
||||||
// Crop the centre and resize to fill exact dimensions (default)
|
fit imagerequest.Fit, format imagerequest.Format,
|
||||||
return cropCentreAndResize(img, width, height)
|
) (int, int, error) {
|
||||||
|
scaleW := float64(width) / float64(imgWidth)
|
||||||
|
scaleH := float64(height) / float64(imgHeight)
|
||||||
|
outputWidth, outputHeight := width, height
|
||||||
|
|
||||||
case FitContain:
|
switch fit {
|
||||||
// Resize to fit within dimensions, maintaining aspect ratio. Scaled
|
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
|
// 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
|
// down to 0, which libvips refuses as a width, so each side is at
|
||||||
// least 1.
|
// least 1.
|
||||||
imgW, imgH := img.Width(), img.Height()
|
|
||||||
scaleW := float64(width) / float64(imgW)
|
|
||||||
scaleH := float64(height) / float64(imgH)
|
|
||||||
scale := min(scaleW, scaleH)
|
scale := min(scaleW, scaleH)
|
||||||
newW := max(1, int(float64(imgW)*scale))
|
outputWidth = max(1, int(float64(imgWidth)*scale))
|
||||||
newH := max(1, int(float64(imgH)*scale))
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
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 FitFill:
|
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.
|
||||||
|
func (p *ImageProcessor) resize(
|
||||||
|
img *vips.ImageRef, width, height int, fit imagerequest.Fit,
|
||||||
|
) error {
|
||||||
|
switch fit {
|
||||||
|
case imagerequest.FitCover, "":
|
||||||
|
// Crop the centre and resize to fill exact dimensions (default)
|
||||||
|
return cropCentreAndResize(img, width, height)
|
||||||
|
|
||||||
|
case imagerequest.FitFill:
|
||||||
// Resize to exact dimensions (may distort)
|
// Resize to exact dimensions (may distort)
|
||||||
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||||
|
|
||||||
case FitInside:
|
case imagerequest.FitInside:
|
||||||
// Same as contain, but only shrink
|
// outputDimensions keeps the dimensions of an image that already fits
|
||||||
if img.Width() <= width && img.Height() <= height {
|
if img.Width() == width && img.Height() == height {
|
||||||
return nil // Already fits
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
return img.Thumbnail(width, height, vips.InterestingNone)
|
||||||
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 imagerequest.FitContain, imagerequest.FitOutside:
|
||||||
|
return img.Thumbnail(width, height, 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:
|
default:
|
||||||
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
return fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +547,8 @@ const defaultQuality = 85
|
|||||||
// to stop libvips once an encode has begun, so when ctx has ended encode
|
// to stop libvips once an encode has begun, so when ctx has ended encode
|
||||||
// returns ctx's error without beginning one.
|
// returns ctx's error without beginning one.
|
||||||
func (p *ImageProcessor) encode(
|
func (p *ImageProcessor) encode(
|
||||||
ctx context.Context, img *vips.ImageRef, format Format, quality int,
|
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
|
||||||
|
quality int,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
err := ctx.Err()
|
err := ctx.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -599,25 +571,25 @@ func (p *ImageProcessor) encode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case imagerequest.FormatJPEG:
|
||||||
return exportJPEG(img, quality)
|
return exportJPEG(img, quality)
|
||||||
|
|
||||||
case FormatPNG:
|
case imagerequest.FormatPNG:
|
||||||
return exportPNG(img)
|
return exportPNG(img)
|
||||||
|
|
||||||
case FormatGIF:
|
case imagerequest.FormatGIF:
|
||||||
return exportGIF(img)
|
return exportGIF(img)
|
||||||
|
|
||||||
case FormatWebP:
|
case imagerequest.FormatWebP:
|
||||||
return exportWebP(img, quality)
|
return exportWebP(img, quality)
|
||||||
|
|
||||||
case FormatAVIF:
|
case imagerequest.FormatAVIF:
|
||||||
return exportAVIF(img, quality)
|
return exportAVIF(img, quality)
|
||||||
|
|
||||||
case FormatJXL:
|
case imagerequest.FormatJXL:
|
||||||
return exportJXL(img, quality)
|
return exportJXL(img, quality)
|
||||||
|
|
||||||
case FormatOriginal:
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -667,11 +639,16 @@ func exportGIF(img *vips.ImageRef) ([]byte, error) {
|
|||||||
return output, err
|
return output, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// webpEffort is libvips' default WebP effort, from 0 (fastest) to 6.
|
// webpEffort is the WebP effort, from 0 (fastest) to 6. It is below libvips'
|
||||||
const webpEffort = 4
|
// 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
|
||||||
|
|
||||||
// exportWebP encodes img as lossy WebP at quality and libvips' default
|
// exportWebP encodes img as lossy WebP at quality and webpEffort, without
|
||||||
// effort, without metadata.
|
// metadata.
|
||||||
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
output, _, err := img.ExportWebp(&vips.WebpExportParams{
|
output, _, err := img.ExportWebp(&vips.WebpExportParams{
|
||||||
StripMetadata: true,
|
StripMetadata: true,
|
||||||
@@ -683,12 +660,13 @@ func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// avifEffort is the AVIF effort, from 0 (fastest) to 9; 1 is the lowest
|
// 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 51
|
// govips can set. With one thread, as pixad runs libvips, 1 takes about 52
|
||||||
// seconds to save an 8192x8192 RGB image of random pixels, and about 110
|
// seconds to save an 8192x8192 RGB image of random pixels at quality 85,
|
||||||
// with an alpha channel, the worst case, against the default
|
// and about 96 with an alpha channel, against the default
|
||||||
// downstream_timeout of 60 seconds. On an image of milder noise, which 1
|
// downstream_timeout of 60 seconds, so an AVIF output is held to
|
||||||
// saves in about 12 seconds, 2 takes nearly a minute and libvips' default,
|
// MaxAVIFOutputPixels. On an image of milder noise, which 1 saves in about
|
||||||
// 4, takes minutes.
|
// 12 seconds, 2 takes nearly a minute and libvips' default, 4, takes
|
||||||
|
// minutes.
|
||||||
const avifEffort = 1
|
const avifEffort = 1
|
||||||
|
|
||||||
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
|
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
|
||||||
@@ -715,10 +693,18 @@ func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
|
|||||||
// JPEG that names none.
|
// JPEG that names none.
|
||||||
const jxlResolution = 72 / 25.4
|
const jxlResolution = 72 / 25.4
|
||||||
|
|
||||||
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
|
// jxlEffort is the JPEG XL effort, from 1 (fastest) to 9. It is below
|
||||||
// and without metadata. govips sends libvips a distance, the JPEG XL
|
// libvips' default of 7 so that an encode takes at most 40 seconds of the
|
||||||
// encoder's own measure of quality, along with the quality, and libvips then
|
// default downstream_timeout of 60 seconds. With one thread, as pixad runs
|
||||||
// uses the distance alone, so the quality is also given as a distance.
|
// 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.
|
||||||
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
|
// 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
|
// PNG, but cannot save one as JPEG XL. encode has already converted
|
||||||
@@ -757,6 +743,7 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
|||||||
params := vips.NewJxlExportParams()
|
params := vips.NewJxlExportParams()
|
||||||
params.Quality = quality
|
params.Quality = quality
|
||||||
params.Distance = jxlDistance(quality)
|
params.Distance = jxlDistance(quality)
|
||||||
|
params.Effort = jxlEffort
|
||||||
|
|
||||||
output, _, err := toSave.ExportJxl(params)
|
output, _, err := toSave.ExportJxl(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -784,20 +771,22 @@ func jxlDistance(quality int) float64 {
|
|||||||
|
|
||||||
// formatFromString converts a format string from detectFormat to Format. A
|
// formatFromString converts a format string from detectFormat to Format. A
|
||||||
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
||||||
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
|
func (p *ImageProcessor) formatFromString(
|
||||||
|
format string,
|
||||||
|
) (imagerequest.Format, error) {
|
||||||
switch format {
|
switch format {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
case "png":
|
case "png":
|
||||||
return FormatPNG, nil
|
return imagerequest.FormatPNG, nil
|
||||||
case "gif":
|
case "gif":
|
||||||
return FormatGIF, nil
|
return imagerequest.FormatGIF, nil
|
||||||
case "webp":
|
case "webp":
|
||||||
return FormatWebP, nil
|
return imagerequest.FormatWebP, nil
|
||||||
case string(FormatAVIF):
|
case string(imagerequest.FormatAVIF):
|
||||||
return FormatAVIF, nil
|
return imagerequest.FormatAVIF, nil
|
||||||
case string(FormatJXL):
|
case string(imagerequest.FormatJXL):
|
||||||
return FormatJXL, nil
|
return imagerequest.FormatJXL, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -87,7 +88,7 @@ func isAVIF(data []byte) bool {
|
|||||||
|
|
||||||
brand := string(data[8:12])
|
brand := string(data[8:12])
|
||||||
|
|
||||||
return brand == string(FormatAVIF) || brand == "avis"
|
return brand == string(imagerequest.FormatAVIF) || brand == "avis"
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectMIME is a minimal magic-byte detector for test assertions.
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
@@ -118,19 +119,19 @@ func detectMIME(data []byte) string {
|
|||||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 400, Height: 300},
|
Size: imagerequest.Size{Width: 400, Height: 300},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -164,18 +165,18 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: imagerequest.Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,23 +197,23 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
// processAndCheckSize processes a test JPEG of the given input dimensions
|
// processAndCheckSize processes a test JPEG of the given input dimensions
|
||||||
// with the requested size and asserts the resulting dimensions.
|
// with the requested size and asserts the resulting dimensions.
|
||||||
func processAndCheckSize(
|
func processAndCheckSize(
|
||||||
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
|
t *testing.T, inputW, inputH int, size imagerequest.Size, wantW, wantH int,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, inputW, inputH)
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: size,
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -232,27 +233,27 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Width and height 0: keep original size
|
// Width and height 0: keep original size
|
||||||
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
|
processAndCheckSize(t, 640, 480, imagerequest.Size{Width: 0, Height: 0}, 640, 480)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
||||||
// Should result in 400x200 (maintaining aspect ratio)
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
input := createTestJPEG(t, 800, 400)
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 400, Height: 400},
|
Size: imagerequest.Size{Width: 400, Height: 400},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,7 +271,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
|||||||
|
|
||||||
// 800x600 image, request width=400 height=0
|
// 800x600 image, request width=400 height=0
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
|
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 400, Height: 0}, 400, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
@@ -278,24 +279,24 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
|||||||
|
|
||||||
// 800x600 image, request width=0 height=300
|
// 800x600 image, request width=0 height=300
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
|
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 0, Height: 300}, 400, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestPNG(t, 400, 300)
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: imagerequest.Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -311,22 +312,6 @@ 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) {
|
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -345,18 +330,18 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
input := createTestJPEG(t, tt.width, tt.height)
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Process() should reject oversized input images")
|
t.Error("Process() should reject oversized input images")
|
||||||
}
|
}
|
||||||
@@ -371,20 +356,20 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image at exactly MaxInputDimension - should be accepted
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
input := createTestJPEG(t, MaxInputDimension, 100)
|
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"Process() should accept images at MaxInputDimension, got error: %v",
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
@@ -397,22 +382,24 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
|
|
||||||
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
||||||
// given format and asserts the output MIME type and dimensions.
|
// given format and asserts the output MIME type and dimensions.
|
||||||
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
func encodeAndCheck(
|
||||||
|
t *testing.T, format imagerequest.Format, quality int, wantMIME string,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -443,13 +430,13 @@ func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
|||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
|
encodeAndCheck(t, imagerequest.FormatWebP, 80, mimeWebP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Load test AVIF file
|
// Load test AVIF file
|
||||||
@@ -459,14 +446,14 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request resize and convert to JPEG
|
// Request resize and convert to JPEG
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 2, Height: 2},
|
Size: imagerequest.Size{Width: 2, Height: 2},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
}
|
}
|
||||||
@@ -491,7 +478,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
// Create a processor with a very small byte limit
|
// Create a processor with a very small byte limit
|
||||||
const limit = 1024
|
const limit = 1024
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: limit})
|
proc := New(Params{MaxInputBytes: limit, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create a valid JPEG that exceeds the byte limit
|
// Create a valid JPEG that exceeds the byte limit
|
||||||
@@ -500,14 +487,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
}
|
}
|
||||||
@@ -524,17 +511,17 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
input := createTestJPEG(t, 10, 10)
|
input := createTestJPEG(t, 10, 10)
|
||||||
limit := int64(len(input)) * 10 // 10× headroom
|
limit := int64(len(input)) * 10 // 10× headroom
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: limit})
|
proc := New(Params{MaxInputBytes: limit, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 10, Height: 10},
|
Size: imagerequest.Size{Width: 10, Height: 10},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -542,35 +529,24 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
defer func() { _ = result.Content.Close() }()
|
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) {
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
encodeAndCheck(t, imagerequest.FormatAVIF, 85, mimeAVIF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndDecode runs input through Process and decodes the output with
|
// processAndDecode runs input through Process and decodes the output with
|
||||||
// vips, so a test can inspect the image a client would receive.
|
// vips, so a test can inspect the image a client would receive.
|
||||||
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
|
func processAndDecode(
|
||||||
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
|
) *vips.ImageRef {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{
|
||||||
context.Background(), bytes.NewReader(input), req,
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
context.Background(), bytes.NewReader(input), transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -614,15 +590,17 @@ func TestImageProcessor_StripsEXIF(t *testing.T) {
|
|||||||
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
||||||
}
|
}
|
||||||
|
|
||||||
formats := []Format{
|
formats := []imagerequest.Format{
|
||||||
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatWebP,
|
||||||
|
imagerequest.FormatAVIF, imagerequest.FormatGIF, imagerequest.FormatOriginal,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, format := range formats {
|
for _, format := range formats {
|
||||||
t.Run(string(format), func(t *testing.T) {
|
t.Run(string(format), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: format})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: format})
|
||||||
|
|
||||||
if output.HasExif() {
|
if output.HasExif() {
|
||||||
t.Errorf("output has EXIF: %v", output.GetExif())
|
t.Errorf("output has EXIF: %v", output.GetExif())
|
||||||
@@ -644,21 +622,21 @@ func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantW int
|
wantW int
|
||||||
wantH int
|
wantH int
|
||||||
}{
|
}{
|
||||||
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
|
{name: "original size", size: imagerequest.Size{}, wantW: 8, wantH: 16},
|
||||||
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
|
{name: "width only", size: imagerequest.Size{Width: 4}, wantW: 4, wantH: 8},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{
|
output := processAndDecode(t, input, &imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
})
|
})
|
||||||
|
|
||||||
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
||||||
@@ -694,7 +672,8 @@ func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatPNG})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
if output.HasICCProfile() {
|
if output.HasICCProfile() {
|
||||||
t.Error("output has an ICC profile")
|
t.Error("output has an ICC profile")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
||||||
@@ -15,10 +16,16 @@ import (
|
|||||||
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
|
transform := &imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
|
Format: imagerequest.FormatJXL,
|
||||||
|
}
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -60,8 +67,12 @@ func TestImageProcessor_JPEGXLQuality(t *testing.T) {
|
|||||||
outputBytes := make(map[int]int64)
|
outputBytes := make(map[int]int64)
|
||||||
|
|
||||||
for _, quality := range []int{30, 90} {
|
for _, quality := range []int{30, 90} {
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +100,8 @@ func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
|
exif := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL}).GetExif()
|
||||||
|
|
||||||
for _, field := range []string{
|
for _, field := range []string{
|
||||||
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
||||||
@@ -115,7 +127,8 @@ func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
// libvips gives the resolution in pixels per millimetre.
|
// libvips gives the resolution in pixels per millimetre.
|
||||||
xDPI := math.Round(output.ResX() * 25.4)
|
xDPI := math.Round(output.ResX() * 25.4)
|
||||||
@@ -140,7 +153,8 @@ func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
if output.Width() != 8 || output.Height() != 16 {
|
if output.Width() != 8 || output.Height() != 16 {
|
||||||
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
||||||
@@ -176,7 +190,8 @@ func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
pixel, err := output.GetPoint(4, 4)
|
pixel, err := output.GetPoint(4, 4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,7 +218,8 @@ func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
if output.Bands() != 3 {
|
if output.Bands() != 3 {
|
||||||
t.Fatalf("output has %d bands, want 3", output.Bands())
|
t.Fatalf("output has %d bands, want 3", output.Bands())
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/iotest"
|
"testing/iotest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errTestReadFailed is the error the unreadable test input returns.
|
// errTestReadFailed is the error the unreadable test input returns.
|
||||||
@@ -76,13 +77,13 @@ func (r *gatedReader) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// smallJPEGRequest asks for a 5x5 JPEG.
|
// smallJPEGTransform asks for a 5x5 JPEG.
|
||||||
func smallJPEGRequest() *Request {
|
func smallJPEGTransform() *imagerequest.Transform {
|
||||||
return &Request{
|
return &imagerequest.Transform{
|
||||||
Size: Size{Width: 5, Height: 5},
|
Size: imagerequest.Size{Width: 5, Height: 5},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ func processInBackground(
|
|||||||
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
) {
|
) {
|
||||||
go func() {
|
go func() {
|
||||||
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
result, err := proc.Process(context.Background(), reader, smallJPEGTransform())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = result.Content.Close()
|
_ = result.Content.Close()
|
||||||
}
|
}
|
||||||
@@ -115,47 +116,15 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
|
func TestNewMakesMaxConcurrentProcessingSlots(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, limit := range []int{0, -1} {
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 3})
|
||||||
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 {
|
if got := cap(proc.processingSemaphore); got != 3 {
|
||||||
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
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
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
||||||
// than MaxConcurrentProcessing allows and holds the first ones inside
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
||||||
// Process until the test lets them go. No more than the limit may be
|
// Process until the test lets them go. No more than the limit may be
|
||||||
@@ -169,7 +138,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|||||||
calls = 6
|
calls = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: limit})
|
||||||
input := createTestJPEG(t, 50, 50)
|
input := createTestJPEG(t, 50, 50)
|
||||||
|
|
||||||
counter := &readingCounter{}
|
counter := &readingCounter{}
|
||||||
@@ -216,7 +185,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|||||||
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
input := createTestJPEG(t, 10, 10)
|
input := createTestJPEG(t, 10, 10)
|
||||||
@@ -237,7 +206,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
smallJPEGRequest())
|
smallJPEGTransform())
|
||||||
if !errors.Is(err, ErrTooManyImages) {
|
if !errors.Is(err, ErrTooManyImages) {
|
||||||
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
||||||
}
|
}
|
||||||
@@ -263,37 +232,37 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
|
|
||||||
valid := createTestJPEG(t, 10, 10)
|
valid := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
unsupported := smallJPEGRequest()
|
unsupported := smallJPEGTransform()
|
||||||
unsupported.Format = "bmp"
|
unsupported.Format = "bmp"
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
input io.Reader
|
input io.Reader
|
||||||
req *Request
|
transform *imagerequest.Transform
|
||||||
// want is the error Process must return; nil means any error.
|
// want is the error Process must return; nil means any error.
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "input cannot be read",
|
name: "input cannot be read",
|
||||||
input: iotest.ErrReader(errTestReadFailed),
|
input: iotest.ErrReader(errTestReadFailed),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
want: errTestReadFailed,
|
want: errTestReadFailed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "input over the byte limit",
|
name: "input over the byte limit",
|
||||||
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
want: ErrInputDataTooLarge,
|
want: ErrInputDataTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "input not an image",
|
name: "input not an image",
|
||||||
input: strings.NewReader("not an image"),
|
input: strings.NewReader("not an image"),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "output format not supported",
|
name: "output format not supported",
|
||||||
input: bytes.NewReader(valid),
|
input: bytes.NewReader(valid),
|
||||||
req: unsupported,
|
transform: unsupported,
|
||||||
want: ErrUnsupportedOutputFormat,
|
want: ErrUnsupportedOutputFormat,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -305,7 +274,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
_, err := proc.Process(context.Background(), tc.input, tc.transform)
|
||||||
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||||
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -315,7 +284,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
smallJPEGRequest())
|
smallJPEGTransform())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() after the error = %v, want nil", err)
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -332,7 +301,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
func TestWaitForProcessing(t *testing.T) {
|
func TestWaitForProcessing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 2})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 2})
|
||||||
|
|
||||||
gate := make(chan struct{})
|
gate := make(chan struct{})
|
||||||
entered := make(chan struct{}, 1)
|
entered := make(chan struct{}, 1)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||||
@@ -26,8 +27,12 @@ func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
|||||||
t.Fatalf("failed to save test TIFF: %v", err)
|
t.Fatalf("failed to save test TIFF: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{
|
||||||
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
t.Context(), bytes.NewReader(tiff),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
|
||||||
)
|
)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() returned %s", result.ContentType)
|
t.Logf("Process() returned %s", result.ContentType)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
|
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
|
||||||
@@ -12,13 +14,15 @@ import (
|
|||||||
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
for _, format := range []Format{
|
for _, format := range []imagerequest.Format{
|
||||||
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
} {
|
} {
|
||||||
saved, err := proc.Process(t.Context(),
|
saved, err := proc.Process(t.Context(),
|
||||||
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
|
bytes.NewReader(createTestPNG(t, 20, 20)),
|
||||||
|
&imagerequest.Transform{Format: format})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("saving the test image as %s: %v", format, err)
|
t.Fatalf("saving the test image as %s: %v", format, err)
|
||||||
}
|
}
|
||||||
@@ -29,7 +33,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
||||||
&Request{Format: FormatOriginal})
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("orig of the %s image: %v", format, err)
|
t.Errorf("orig of the %s image: %v", format, err)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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[:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL parsing errors.
|
// URL parsing errors.
|
||||||
@@ -25,8 +23,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// MaxDimension is the maximum allowed width or height, the same as the image
|
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||||
// processor's maximum for an output image.
|
// processor's maximum for an output image (imageprocessor.MaxOutputDimension).
|
||||||
const MaxDimension = imageprocessor.MaxOutputDimension
|
const MaxDimension = 8192
|
||||||
|
|
||||||
// ValidateDimension checks one requested width or height; name ("width" or
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
// "height") appears in the error. 0 is valid: that side is then worked out from
|
// "height") appears in the error. 0 is valid: that side is then worked out from
|
||||||
@@ -68,42 +66,11 @@ var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
|
|||||||
var hostRegex = regexp.MustCompile(
|
var hostRegex = regexp.MustCompile(
|
||||||
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
||||||
|
|
||||||
// ParsedURL contains the parsed components of an image proxy URL.
|
|
||||||
type ParsedURL struct {
|
|
||||||
// Host is the source origin host (e.g., "cdn.example.com")
|
|
||||||
Host string
|
|
||||||
// Path is the path on the origin (e.g., "/photos/cat.jpg")
|
|
||||||
Path string
|
|
||||||
// Query is the optional query string for the origin
|
|
||||||
Query string
|
|
||||||
// Size is the requested output dimensions
|
|
||||||
Size Size
|
|
||||||
// Format is the requested output format
|
|
||||||
Format ImageFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
|
|
||||||
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
|
|
||||||
// Examples:
|
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
|
||||||
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
|
||||||
// - cdn.example.com/photos/cat.jpg/orig.png
|
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600
|
|
||||||
func ParseImagePath(path string) (*ParsedURL, error) {
|
|
||||||
// Strip leading slash if present (chi may include it)
|
|
||||||
path = strings.TrimPrefix(path, "/")
|
|
||||||
if path == "" {
|
|
||||||
return nil, ErrMissingHost
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseImageComponents(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
||||||
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
|
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
|
||||||
// for JPEG XL. A source query follows the source path after a "?", which the
|
// for JPEG XL. A source query follows the source path after a "?", which the
|
||||||
// URL writes as %3F.
|
// URL writes as %3F.
|
||||||
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
func ParseImageURL(urlPath string) (*Request, error) {
|
||||||
// Remove the /v1/image/ prefix
|
// Remove the /v1/image/ prefix
|
||||||
const prefix = "/v1/image/"
|
const prefix = "/v1/image/"
|
||||||
if !strings.HasPrefix(urlPath, prefix) {
|
if !strings.HasPrefix(urlPath, prefix) {
|
||||||
@@ -120,7 +87,7 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
|
|
||||||
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||||
// <host>/<path>/<size> for JPEG XL.
|
// <host>/<path>/<size> for JPEG XL.
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*Request, error) {
|
||||||
// Find the last path segment, which holds "size" or "size.format"
|
// Find the last path segment, which holds "size" or "size.format"
|
||||||
lastSlash := strings.LastIndex(remainder, "/")
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
if lastSlash == -1 {
|
if lastSlash == -1 {
|
||||||
@@ -159,15 +126,15 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
return nil, ErrMissingHost
|
return nil, ErrMissingHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
|
// Later steps put the host unparsed into the allowlist check, the cache key
|
||||||
// the host would make Go's URL parser, which the allowlist check and the
|
// and the upstream URL, so only the forms hostRegex describes are accepted
|
||||||
// fetcher use, read a different host from the one allowlisted and cached
|
|
||||||
hostMatch := hostRegex.FindStringSubmatch(host)
|
hostMatch := hostRegex.FindStringSubmatch(host)
|
||||||
if hostMatch == nil {
|
if hostMatch == nil {
|
||||||
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go's URL parser refuses brackets around anything but an IPv6 address
|
// The allowlist check and the fetcher drop the brackets, so brackets
|
||||||
|
// around anything but an IPv6 address would give a host a second spelling
|
||||||
if inBrackets := hostMatch[1]; inBrackets != "" {
|
if inBrackets := hostMatch[1]; inBrackets != "" {
|
||||||
addr, err := netip.ParseAddr(inBrackets)
|
addr, err := netip.ParseAddr(inBrackets)
|
||||||
if err != nil || !addr.Is6() {
|
if err != nil || !addr.Is6() {
|
||||||
@@ -180,9 +147,8 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
// host
|
// host
|
||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
// Go's URL parser, which the fetcher uses, reads a "#" as the start of a
|
// A "#" starts a URL's fragment, so neither the source path nor the source
|
||||||
// fragment and drops the rest, so each text after it would be another
|
// query may hold one
|
||||||
// spelling, cached under its own keys, of one upstream request
|
|
||||||
if strings.Contains(path, "#") {
|
if strings.Contains(path, "#") {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
`%w: the source path or query holds a "#"`, ErrInvalidPath)
|
`%w: the source path or query holds a "#"`, ErrInvalidPath)
|
||||||
@@ -209,12 +175,9 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
path = "/" + path
|
path = "/" + path
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ParsedURL{
|
return &Request{
|
||||||
Host: host,
|
Source: Source{Host: host, Path: path, Query: query},
|
||||||
Path: path,
|
Transform: Transform{Size: size, Format: format},
|
||||||
Query: query,
|
|
||||||
Size: size,
|
|
||||||
Format: format,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +260,7 @@ func escapeQuery(query string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||||
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
func parseSizeFormat(s string) (Size, Format, error) {
|
||||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
@@ -351,8 +314,8 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return size, format, nil
|
return size, format, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFormat converts a format string to ImageFormat.
|
// parseFormat converts a format string to Format.
|
||||||
func parseFormat(s string) (ImageFormat, error) {
|
func parseFormat(s string) (Format, error) {
|
||||||
switch strings.ToLower(s) {
|
switch strings.ToLower(s) {
|
||||||
case "orig", "original":
|
case "orig", "original":
|
||||||
return FormatOriginal, nil
|
return FormatOriginal, nil
|
||||||
@@ -374,14 +337,3 @@ func parseFormat(s string) (ImageFormat, error) {
|
|||||||
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToImageRequest converts a ParsedURL to an ImageRequest.
|
|
||||||
func (p *ParsedURL) ToImageRequest() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: p.Host,
|
|
||||||
SourcePath: p.Path,
|
|
||||||
SourceQuery: p.Query,
|
|
||||||
Size: p.Size,
|
|
||||||
Format: p.Format,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+77
-181
@@ -1,33 +1,41 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assertParsedURL compares all fields of a parsed URL against the
|
// Shared test fixture strings.
|
||||||
// expected value.
|
const (
|
||||||
func assertParsedURL(t *testing.T, got, want *ParsedURL) {
|
testHostCDN = "cdn.example.com"
|
||||||
|
testHostExample = "example.com"
|
||||||
|
testPathCat = "/photos/cat.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertRequest compares the source host, path and query and the size and
|
||||||
|
// format of got against want.
|
||||||
|
func assertRequest(t *testing.T, got, want *Request) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if got.Host != want.Host {
|
if got.Source.Host != want.Source.Host {
|
||||||
t.Errorf("Host = %q, want %q", got.Host, want.Host)
|
t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Path != want.Path {
|
if got.Source.Path != want.Source.Path {
|
||||||
t.Errorf("Path = %q, want %q", got.Path, want.Path)
|
t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Query != want.Query {
|
if got.Source.Query != want.Source.Query {
|
||||||
t.Errorf("Query = %q, want %q", got.Query, want.Query)
|
t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Size != want.Size {
|
if got.Transform.Size != want.Transform.Size {
|
||||||
t.Errorf("Size = %v, want %v", got.Size, want.Size)
|
t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Format != want.Format {
|
if got.Transform.Format != want.Transform.Format {
|
||||||
t.Errorf("Format = %q, want %q", got.Format, want.Format)
|
t.Errorf("Format = %q, want %q",
|
||||||
|
got.Transform.Format, want.Transform.Format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,62 +45,64 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want *ParsedURL
|
want *Request
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic path with size",
|
name: "basic path with size",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat,
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with 0x0",
|
name: "original size with 0x0",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat,
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
Size: Size{Width: 0, Height: 0}, Format: FormatJPEG,
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with orig keyword",
|
name: "original size with orig keyword",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat,
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
Size: Size{Width: 0, Height: 0}, Format: FormatPNG,
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "path with query string",
|
name: "path with query string",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deep nested path",
|
name: "deep nested path",
|
||||||
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: "/a/b/c/d/image.jpg",
|
Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
|
||||||
Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF,
|
Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "jpg alias for jpeg",
|
name: "jpg alias for jpeg",
|
||||||
input: "/v1/image/example.com/img.png/100x100.jpg",
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostExample, Path: "/img.png",
|
Source: Source{Host: testHostExample, Path: "/img.png"},
|
||||||
Size: Size{Width: 100, Height: 100}, Format: FormatJPEG,
|
Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gif format",
|
name: "gif format",
|
||||||
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostExample, Path: "/animated.gif",
|
Source: Source{Host: testHostExample, Path: "/animated.gif"},
|
||||||
Size: Size{Width: 200, Height: 200}, Format: FormatGIF,
|
Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -106,7 +116,7 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertParsedURL(t, got, tt.want)
|
assertRequest(t, got, tt.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,8 +131,8 @@ func TestParseImageURL_AutoFormat(t *testing.T) {
|
|||||||
t.Fatalf("ParseImageURL() error = %v", err)
|
t.Fatalf("ParseImageURL() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Format != FormatAuto {
|
if got.Transform.Format != FormatAuto {
|
||||||
t.Errorf("Format = %q, want %q", got.Format, FormatAuto)
|
t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,135 +192,49 @@ func TestParseImageURL_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath(t *testing.T) {
|
// TestParseImageURL_LowerCasesHost verifies that the host is lower-cased, so
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want *ParsedURL
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "chi wildcard capture",
|
|
||||||
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
||||||
want: &ParsedURL{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with leading slash from chi",
|
|
||||||
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
||||||
want: &ParsedURL{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got, err := ParseImagePath(tt.input)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assertParsedURL(t, got, tt.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
|
||||||
// that every spelling of its letter case is the same host to the allowlist,
|
// that every spelling of its letter case is the same host to the allowlist,
|
||||||
// the upstream connection limits and the cache, and that the path keeps its
|
// the upstream connection limits and the cache, and that the path keeps its
|
||||||
// case.
|
// case.
|
||||||
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
func TestParseImageURL_LowerCasesHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
got, err := ParseImageURL("/v1/image/CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseImagePath() error = %v", err)
|
t.Fatalf("ParseImageURL() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("parsed host %q, path %q", got.Host, got.Path)
|
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
||||||
|
|
||||||
if got.Host != testHostCDN {
|
if got.Source.Host != testHostCDN {
|
||||||
t.Errorf("Host = %q, want %q", got.Host, testHostCDN)
|
t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Path != "/Photos/Cat.jpg" {
|
if got.Source.Path != "/Photos/Cat.jpg" {
|
||||||
t.Errorf("Path = %q, want %q", got.Path, "/Photos/Cat.jpg")
|
t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
// TestParseImageURL_RefusesUserinfo verifies that a host holding "@" is
|
||||||
// refused: a URL reads what comes before the "@" as a user name and password
|
// refused: a URL reads what comes before the "@" as a user name and password
|
||||||
// and the rest as the host.
|
// and the rest as the host.
|
||||||
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
func TestParseImageURL_RefusesUserinfo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, input := range []string{
|
for _, input := range []string{
|
||||||
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"/v1/image/user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"/v1/image/user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
} {
|
} {
|
||||||
got, err := ParseImagePath(input)
|
got, err := ParseImageURL(input)
|
||||||
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
|
||||||
|
|
||||||
if !errors.Is(err, ErrInvalidHost) {
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
t.Errorf("ParseImageURL(%q) error = %v, want %v",
|
||||||
input, err, ErrInvalidHost)
|
input, err, ErrInvalidHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsedURL_ToImageRequest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parsed := &ParsedURL{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
Query: "version=2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
if req.SourceHost != parsed.Host {
|
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.SourcePath != parsed.Path {
|
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.SourceQuery != parsed.Query {
|
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Size != parsed.Size {
|
|
||||||
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Format != parsed.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseImageURL_PathTraversal(t *testing.T) {
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -331,6 +255,10 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
name: "parent in middle of path",
|
name: "parent in middle of path",
|
||||||
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "parent directory in path",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/../secret/800x600.jpeg",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "encoded parent directory",
|
name: "encoded parent directory",
|
||||||
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
||||||
@@ -373,40 +301,6 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath_PathTraversal(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Test path traversal via ParseImagePath (chi wildcard)
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "parent directory in path",
|
|
||||||
input: "cdn.example.com/photos/../secret/800x600.jpeg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "encoded traversal",
|
|
||||||
input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := ParseImagePath(tt.input)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("ParseImagePath() should reject path traversal attempts")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrPathTraversal) {
|
|
||||||
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
||||||
// a source query holding %25, the percent-escape of a literal "%", are
|
// a source query holding %25, the percent-escape of a literal "%", are
|
||||||
// accepted as they are, although decoding them once more leaves a "%" that
|
// accepted as they are, although decoding them once more leaves a "%" that
|
||||||
@@ -417,22 +311,24 @@ func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want *ParsedURL
|
want *Request
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "path",
|
name: "path",
|
||||||
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: "/sale/50%25off.jpg",
|
Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "query",
|
name: "query",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -446,7 +342,7 @@ func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
|||||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertParsedURL(t, got, tt.want)
|
assertRequest(t, got, tt.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-8
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,35 +10,43 @@ func TestValidateImageRequest(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
req ImageRequest
|
req Request
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "within bounds",
|
name: "within bounds",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, FitMode: FitCover,
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size and empty fit",
|
name: "original size and empty fit",
|
||||||
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
req: Request{Transform: Transform{Size: Size{Width: 0, Height: 0}}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "width over limit",
|
name: "width over limit",
|
||||||
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: MaxDimension + 1, Height: 600},
|
||||||
|
}},
|
||||||
wantErr: ErrDimensionTooLarge,
|
wantErr: ErrDimensionTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "height over limit",
|
name: "height over limit",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: MaxDimension + 1},
|
||||||
|
}},
|
||||||
wantErr: ErrDimensionTooLarge,
|
wantErr: ErrDimensionTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "negative width",
|
name: "negative width",
|
||||||
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
req: Request{Transform: Transform{Size: Size{Width: -1, Height: 600}}},
|
||||||
wantErr: ErrInvalidSize,
|
wantErr: ErrInvalidSize,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid fit mode",
|
name: "invalid fit mode",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, FitMode: "bogus",
|
||||||
|
}},
|
||||||
wantErr: ErrInvalidFitMode,
|
wantErr: ErrInvalidFitMode,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
-513
@@ -2,35 +2,23 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache errors.
|
// maxPendingAccessTimes is how many access times of variants served from
|
||||||
var (
|
// the cache, and how many of cached sources a miss used, may each wait to be
|
||||||
ErrCacheMiss = errors.New("cache miss")
|
// written, at about 80 bytes each. An access time handed over while that
|
||||||
ErrNegativeCache = errors.New("negative cache hit")
|
// many of its kind wait is dropped.
|
||||||
)
|
const maxPendingAccessTimes = 10000
|
||||||
|
|
||||||
// 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
|
// cleanExpiredBatchSize is the most expired negative cache rows one
|
||||||
// statement of CleanExpired deletes. Each batch is a statement of its
|
// statement of CleanExpired deletes. Each batch is a statement of its
|
||||||
@@ -44,23 +32,12 @@ type CacheConfig struct {
|
|||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
|
|
||||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
// enforces. Zero means no limit is enforced (no 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.
|
||||||
MaxBytes int64
|
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 receives accounting and eviction log output. A nil
|
||||||
// Logger means slog.Default().
|
// Logger means slog.Default().
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
@@ -69,16 +46,11 @@ type CacheConfig struct {
|
|||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
srcContent *ContentStorage // source images by content hash
|
srcContent *ContentStorage // source images by source key
|
||||||
variants *VariantStorage // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
|
||||||
config CacheConfig
|
config CacheConfig
|
||||||
log *slog.Logger
|
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
|
// Eviction machinery. The channels are created in NewCache so
|
||||||
// stores can signal write pressure without racing StartEviction.
|
// stores can signal write pressure without racing StartEviction.
|
||||||
// evictionCancel, set by StartEviction, cancels the eviction
|
// evictionCancel, set by StartEviction, cancels the eviction
|
||||||
@@ -87,47 +59,30 @@ type Cache struct {
|
|||||||
evictionDone chan struct{}
|
evictionDone chan struct{}
|
||||||
evictionCancel context.CancelFunc
|
evictionCancel context.CancelFunc
|
||||||
|
|
||||||
// The pending counts: hits, misses, upstream fetches and transforms
|
// pendingVariantAccessTimes holds the variants served from the cache,
|
||||||
// whose write to the database missed its request's deadline, kept
|
// and pendingSourceAccessTimes the cached sources a miss used, whose
|
||||||
// here until they are written by the goroutine that
|
// access time is still to be written by the goroutine that
|
||||||
// StartPendingCountWrites starts. As for eviction, the channels are
|
// StartPendingAccessTimeWrites starts, so that no request waits for the
|
||||||
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
// database to record it. As for eviction, the channels are created in
|
||||||
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
// NewCache, so that requests can hand over access times before that
|
||||||
// set by StartPendingCountWrites, cancels its context, which tells it
|
// goroutine starts; pendingAccessTimesDone is closed when it returns.
|
||||||
// to finish.
|
// pendingAccessTimesCancel, set by StartPendingAccessTimeWrites,
|
||||||
pendingHits atomic.Int64
|
// cancels its context, which tells it to finish.
|
||||||
pendingMisses atomic.Int64
|
pendingVariantAccessTimes chan imagerequest.VariantKey
|
||||||
pendingUpstreamFetches atomic.Int64
|
pendingSourceAccessTimes chan imagerequest.SourceKey
|
||||||
pendingUpstreamFetchBytes atomic.Int64
|
pendingAccessTimesDone chan struct{}
|
||||||
pendingTransforms atomic.Int64
|
pendingAccessTimesCancel context.CancelFunc
|
||||||
pendingCountsAdded chan struct{}
|
|
||||||
pendingCountsDone chan struct{}
|
|
||||||
pendingCountsCancel context.CancelFunc
|
|
||||||
|
|
||||||
// Held through each write of the pending counts, and by
|
// The counts of what the cache does, made and registered by
|
||||||
// readHitAndMissCounts to read them
|
// registerCounters (see counters.go)
|
||||||
pendingCountsWriteMutex sync.Mutex
|
cacheHits prometheus.Counter
|
||||||
|
cacheMisses prometheus.Counter
|
||||||
// metaCache holds the content types of the variants most recently
|
upstreamFetches prometheus.Counter
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
upstreamFetchBytes prometheus.Counter
|
||||||
// It never stands in for the variant file, which is always opened.
|
transforms prometheus.Counter
|
||||||
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
|
// reconciliationPageSize is the most rows one read of a content table
|
||||||
// returns in the reconciliation pass and in Stats. newCache sets it to
|
// returns in the reconciliation pass. NewCache sets it to
|
||||||
// defaultReconciliationPageSize; tests set it smaller.
|
// defaultReconciliationPageSize; tests set it smaller.
|
||||||
reconciliationPageSize int
|
reconciliationPageSize int
|
||||||
|
|
||||||
@@ -138,43 +93,37 @@ type Cache struct {
|
|||||||
reconciliationReadTestHook func(rows int)
|
reconciliationReadTestHook func(rows int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a new cache instance.
|
// NewCache creates a new cache instance and registers its counters on
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
// metricsRegistry. pixad passes Prometheus' default registry, the one
|
||||||
return newCache(db, config, defaultFreeSpaceProbe)
|
// /metrics serves. It makes no database call.
|
||||||
}
|
func NewCache(
|
||||||
|
db *sql.DB, config CacheConfig, metricsRegistry prometheus.Registerer,
|
||||||
// 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) {
|
) (*Cache, error) {
|
||||||
log := config.Logger
|
log := config.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
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{
|
c := &Cache{
|
||||||
db: db,
|
db: db,
|
||||||
config: config,
|
config: config,
|
||||||
log: log,
|
log: log,
|
||||||
disabled: config.DisableDiskCache,
|
|
||||||
evictionPressure: make(chan struct{}, 1),
|
evictionPressure: make(chan struct{}, 1),
|
||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: metaCache,
|
|
||||||
contentLocks: newContentLock(),
|
|
||||||
|
|
||||||
pendingCountsAdded: make(chan struct{}, 1),
|
pendingVariantAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
|
||||||
pendingCountsDone: make(chan struct{}),
|
pendingSourceAccessTimes: make(chan imagerequest.SourceKey, maxPendingAccessTimes),
|
||||||
|
pendingAccessTimesDone: make(chan struct{}),
|
||||||
|
|
||||||
reconciliationPageSize: defaultReconciliationPageSize,
|
reconciliationPageSize: defaultReconciliationPageSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.disabled {
|
err := c.registerCounters(metricsRegistry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.MaxBytes == 0 {
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,180 +135,80 @@ func newCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(
|
variants, err := NewVariantStorage(
|
||||||
filepath.Join(config.StateDir, "cache", "variants"), log,
|
filepath.Join(config.StateDir, "cache", "variants"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
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.srcContent = srcContent
|
||||||
c.variants = variants
|
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
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupResult contains the result of a cache lookup.
|
// GetVariant opens a cached variant and returns a reader, its size and its
|
||||||
type LookupResult struct {
|
// content type, read from the variant file's first bytes. It opens that one
|
||||||
Hit bool
|
// file and makes no database call. A variant not in the cache, or any
|
||||||
CacheKey VariantKey
|
// variant of a disabled cache, is ErrNotFound; a variant file whose first
|
||||||
ContentType string
|
// bytes are not those of an output format is another error.
|
||||||
SizeBytes int64
|
func (c *Cache) GetVariant(
|
||||||
CacheStatus CacheStatus
|
cacheKey imagerequest.VariantKey,
|
||||||
}
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
if c.config.MaxBytes == 0 {
|
||||||
// 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
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType, known := c.metaCache.Get(cacheKey)
|
return c.variants.LoadWithContentType(cacheKey)
|
||||||
if !known {
|
|
||||||
return c.loadVariantWithMeta(cacheKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
// StoreSource stores fetched source content: it writes the file named by
|
||||||
if err != nil {
|
// the source's key, then the source's row. When the row cannot be written
|
||||||
// The file is gone, e.g. deleted outside pixa
|
// the file is removed and the store fails, as a file with no row is never
|
||||||
c.metaCache.Remove(cacheKey)
|
// 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
|
||||||
return nil, 0, "", err
|
// empty key.
|
||||||
}
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata. On a
|
|
||||||
// disabled cache it is a no-op returning an empty hash.
|
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *imagerequest.Request,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *httpfetcher.FetchResult,
|
) (imagerequest.SourceKey, error) {
|
||||||
) (ContentHash, error) {
|
if c.config.MaxBytes == 0 {
|
||||||
if c.disabled {
|
|
||||||
return "", nil
|
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)
|
data, err := io.ReadAll(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read source content: %w", err)
|
return "", fmt.Errorf("failed to read source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := sha256.Sum256(data)
|
key := req.Source.Key()
|
||||||
contentHash := ContentHash(hex.EncodeToString(sum[:]))
|
|
||||||
|
|
||||||
// Hold the content hash's lock for the whole store operation. A
|
size, err := c.srcContent.Store(key, data)
|
||||||
// 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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in database
|
_, err = c.db.ExecContext(ctx, `
|
||||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
INSERT INTO source_content (source_key, size_bytes)
|
||||||
|
VALUES (?, ?)
|
||||||
headersJSON, err := json.Marshal(result.Headers)
|
ON CONFLICT(source_key) DO UPDATE SET
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(key), size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, 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)
|
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()
|
c.notifyWritePressure()
|
||||||
|
|
||||||
return contentHash, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key and records
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
@@ -367,27 +216,24 @@ func (c *Cache) StoreSource(
|
|||||||
// accounting insert is best-effort (the startup reconciliation pass
|
// accounting insert is best-effort (the startup reconciliation pass
|
||||||
// adopts any variant file that misses its accounting row).
|
// adopts any variant file that misses its accounting row).
|
||||||
func (c *Cache) StoreVariant(
|
func (c *Cache) StoreVariant(
|
||||||
ctx context.Context, cacheKey VariantKey, content io.Reader, contentType string,
|
ctx context.Context, cacheKey imagerequest.VariantKey, content io.Reader,
|
||||||
) error {
|
) error {
|
||||||
if c.disabled {
|
if c.config.MaxBytes == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
size, err := c.variants.Store(cacheKey, content, contentType)
|
size, err := c.variants.Store(cacheKey, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Add(cacheKey, contentType)
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
INSERT INTO variant_content (cache_key, size_bytes)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
size_bytes = excluded.size_bytes,
|
size_bytes = excluded.size_bytes,
|
||||||
content_type = excluded.content_type,
|
|
||||||
last_accessed_at = CURRENT_TIMESTAMP
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
`, string(cacheKey), size, contentType)
|
`, string(cacheKey), size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("failed to record variant in size accounting",
|
c.log.Warn("failed to record variant in size accounting",
|
||||||
"cache_key", cacheKey, "error", err)
|
"cache_key", cacheKey, "error", err)
|
||||||
@@ -399,60 +245,53 @@ func (c *Cache) StoreVariant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values
|
// Returns the source key if found, or an empty key if not; a row whose
|
||||||
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
|
// file is missing is not found. A source found has its access time
|
||||||
// reports no cached source.
|
// handed to the background writer without waiting (see
|
||||||
|
// touchSourceContent); a disabled cache always reports no cached source.
|
||||||
func (c *Cache) LookupSource(
|
func (c *Cache) LookupSource(
|
||||||
ctx context.Context, req *ImageRequest,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (ContentHash, string, error) {
|
) (imagerequest.SourceKey, error) {
|
||||||
if c.disabled {
|
if c.config.MaxBytes == 0 {
|
||||||
return "", "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hashStr, contentType string
|
key := req.Source.Key()
|
||||||
|
|
||||||
|
var rowExists bool
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT content_hash, content_type FROM source_metadata
|
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
`, string(key)).Scan(&rowExists)
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&hashStr, &contentType)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to lookup source: %w", err)
|
return "", fmt.Errorf("failed to lookup source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHash := ContentHash(hashStr)
|
if !rowExists {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the content file exists
|
// Verify the content file exists
|
||||||
if !c.srcContent.Exists(contentHash) {
|
if !c.srcContent.Exists(key) {
|
||||||
return "", "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.touchSourceContent(ctx, contentHash)
|
c.touchSourceContent(key)
|
||||||
|
|
||||||
return contentHash, contentType, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
func (c *Cache) StoreNegative(
|
func (c *Cache) StoreNegative(ctx context.Context, req *imagerequest.Request) error {
|
||||||
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
|
|
||||||
) error {
|
|
||||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, source_query, status_code,
|
(source_host, source_path, source_query, expires_at)
|
||||||
error_message, expires_at)
|
VALUES (?, ?, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
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
|
expires_at = excluded.expires_at
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery, statusCode, errMsg, expiresAt)
|
`, req.Source.Host, req.Source.Path, req.Source.Query, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert negative cache: %w", err)
|
return fmt.Errorf("failed to insert negative cache: %w", err)
|
||||||
}
|
}
|
||||||
@@ -460,33 +299,16 @@ func (c *Cache) StoreNegative(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
// GetSourceContent returns a reader for cached source content by its key,
|
||||||
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.
|
// and the content's size in bytes.
|
||||||
func (c *Cache) GetSourceContent(
|
func (c *Cache) GetSourceContent(
|
||||||
contentHash ContentHash,
|
key imagerequest.SourceKey,
|
||||||
) (io.ReadCloser, int64, error) {
|
) (io.ReadCloser, int64, error) {
|
||||||
if c.disabled {
|
if c.config.MaxBytes == 0 {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.srcContent.LoadWithSize(contentHash)
|
return c.srcContent.LoadWithSize(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired removes the negative cache rows past their expiry, one
|
// CleanExpired removes the negative cache rows past their expiry, one
|
||||||
@@ -512,241 +334,72 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||||
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
||||||
// methods, it writes to the database with ctx's deadline but not its
|
// methods, it only adds to the cache's Prometheus counters in memory (see
|
||||||
// cancellation, so an ended request is still counted but does not wait for
|
// counters.go), so it never waits for the database or fails.
|
||||||
// the database past its deadline; a count not written by then becomes a
|
func (c *Cache) IncrementStats(hit bool, fetchBytes int64) {
|
||||||
// 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 {
|
if hit {
|
||||||
pendingCount = &c.pendingHits
|
c.cacheHits.Inc()
|
||||||
|
|
||||||
_, err = c.db.ExecContext(countCtx, `
|
|
||||||
UPDATE cache_stats
|
|
||||||
SET hit_count = hit_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
} else {
|
} else {
|
||||||
_, err = c.db.ExecContext(countCtx, `
|
c.cacheMisses.Inc()
|
||||||
UPDATE cache_stats
|
|
||||||
SET miss_count = miss_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
c.IncrementUpstreamFetch(fetchBytes)
|
||||||
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.
|
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||||
// A fetch that read no bytes is not counted.
|
// A fetch that read no bytes is not counted.
|
||||||
func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
func (c *Cache) IncrementUpstreamFetch(fetchBytes int64) {
|
||||||
if fetchBytes <= 0 {
|
if fetchBytes <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
c.upstreamFetches.Inc()
|
||||||
defer cancel()
|
c.upstreamFetchBytes.Add(float64(fetchBytes))
|
||||||
|
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
func (c *Cache) IncrementTransformCount() {
|
||||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
c.transforms.Inc()
|
||||||
defer cancel()
|
}
|
||||||
|
|
||||||
_, err := c.db.ExecContext(countCtx, `
|
// touchVariant hands the access time of a variant served from the cache to
|
||||||
UPDATE cache_stats
|
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
|
||||||
SET transform_count = transform_count + 1,
|
// next batch. It never waits: while maxPendingAccessTimes access times of
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
// variants wait to be written, this one is dropped, and the variant only
|
||||||
WHERE id = 1
|
// looks colder to eviction.
|
||||||
`)
|
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
||||||
|
select {
|
||||||
switch {
|
case c.pendingVariantAccessTimes <- cacheKey:
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
default:
|
||||||
c.addPendingCount(&c.pendingTransforms, 1)
|
|
||||||
case err != nil:
|
|
||||||
c.log.Warn("failed to count transform", "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
// touchSourceContent hands the access time of a cached source a miss used to
|
||||||
// cache_stats row plus the pending hits and misses (see
|
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
|
||||||
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
|
// next batch. It never waits: while maxPendingAccessTimes access times of
|
||||||
// write of the pending counts falls between the two reads.
|
// sources wait to be written, this one is dropped, and the source only looks
|
||||||
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
|
// colder to eviction.
|
||||||
c.pendingCountsWriteMutex.Lock()
|
func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
|
||||||
defer c.pendingCountsWriteMutex.Unlock()
|
select {
|
||||||
|
case c.pendingSourceAccessTimes <- key:
|
||||||
err := c.db.QueryRowContext(ctx, `
|
default:
|
||||||
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:
|
// checkNegativeCache checks if a request is in the negative cache. It only
|
||||||
// a failed touch only makes the entry look colder to eviction.
|
// reads: a row past its expiry is not a hit, and the eviction goroutine
|
||||||
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
|
// deletes it with the other expired rows (see CleanExpired).
|
||||||
_, 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(
|
func (c *Cache) checkNegativeCache(
|
||||||
ctx context.Context, req *ImageRequest,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
var expiresAt time.Time
|
var expiresAt time.Time
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT expires_at FROM negative_cache
|
SELECT expires_at FROM negative_cache
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
`, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&expiresAt)
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -756,22 +409,9 @@ func (c *Cache) checkNegativeCache(
|
|||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
expired := time.Now().After(expiresAt)
|
||||||
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 !expired, nil
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetVariant_ContentTypeFromFirstBytes stores an image in each output
|
||||||
|
// format as a variant and checks that GetVariant serves it whole with the
|
||||||
|
// content type of its format, read from the file.
|
||||||
|
func TestGetVariant_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.StoreVariant(t.Context(), key, bytes.NewReader(image))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreVariant(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, contentType, err := cache.GetVariant(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVariant(%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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetVariant_RefusesFileOfNoOutputFormat checks that GetVariant 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 TestGetVariant_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.GetVariant(key)
|
||||||
|
t.Logf("%s: GetVariant() error = %v", tt.name, err)
|
||||||
|
|
||||||
|
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("%s: GetVariant() = reader %v, content type %q, error %v; "+
|
||||||
|
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, _, err := cache.GetVariant(testVariantKeyFour)
|
||||||
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant() 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.GetVariant(testVariantKeyOne)
|
||||||
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant() on a disabled cache = reader %v, error %v; "+
|
||||||
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_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 TestService_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"), 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_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 TestService_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(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.cache.db.SetMaxOpenConns(1)
|
||||||
|
svc.cache.StartPendingAccessTimeWrites()
|
||||||
|
|
||||||
|
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||||
|
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setVariantLastAccessed(t, svc.cache, key, anHourAgo)
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
|
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.cache, `
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -23,67 +22,18 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
// Create schema
|
// Create schema
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE source_content (
|
CREATE TABLE source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_accessed_at DATETIME NOT NULL 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 (
|
CREATE TABLE negative_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
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,
|
expires_at DATETIME NOT NULL,
|
||||||
UNIQUE(source_host, source_path, source_query)
|
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)
|
_, err = db.ExecContext(t.Context(), schema)
|
||||||
@@ -94,361 +44,6 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
return 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) {
|
func TestCache_CleanExpired(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -458,15 +53,16 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
cache, _ := NewCache(db, CacheConfig{
|
cache, _ := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
NegativeTTL: 1 * time.Millisecond,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Insert expired negative cache entry directly
|
// Insert expired negative cache entry directly
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, source_query, status_code, expires_at)
|
(source_host, source_path, source_query, expires_at)
|
||||||
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
VALUES ('example.com', '/old.jpg', '', datetime('now', '-1 hour'))
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert test data: %v", err)
|
t.Fatalf("failed to insert test data: %v", err)
|
||||||
@@ -509,7 +105,8 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
|
|
||||||
_, err := NewCache(db, CacheConfig{
|
_, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewCache() error = %v", err)
|
t.Fatalf("NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -518,7 +115,6 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
dirs := []string{
|
dirs := []string{
|
||||||
"cache/sources",
|
"cache/sources",
|
||||||
"cache/variants",
|
"cache/variants",
|
||||||
"cache/metadata",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
|
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
|
||||||
@@ -29,10 +31,10 @@ func TestEvictionReadsTheUsageTotal(t *testing.T) {
|
|||||||
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||||
bytes.Repeat([]byte{0x75}, 200))
|
bytes.Repeat([]byte{0x75}, 200))
|
||||||
|
|
||||||
// pixa never changes a source's size, but a statement that does must
|
// Storing a source URL again changes its size, so the total must
|
||||||
// change the total too.
|
// follow a statement that changes it.
|
||||||
_, err := cache.db.ExecContext(ctx,
|
_, err := cache.db.ExecContext(ctx,
|
||||||
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
|
`UPDATE source_content SET size_bytes = 900 WHERE source_key = ?`,
|
||||||
string(kept))
|
string(kept))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to change the source's size: %v", err)
|
t.Fatalf("failed to change the source's size: %v", err)
|
||||||
@@ -153,8 +155,9 @@ func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
|
|||||||
// pass. No read the pass makes of a content table, to check its rows or
|
// 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
|
// 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
|
// for one page at most. Between them the reads must still cover every
|
||||||
// row of both tables twice, once to check it and once to sum it, and the
|
// variant row twice, once to check it and once to sum it, and every
|
||||||
// sum must put a wrong total right.
|
// source row once, as source rows are read only to sum them, and the sum
|
||||||
|
// must put a wrong total right.
|
||||||
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -171,7 +174,8 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
|||||||
|
|
||||||
storeEvictionTestSource(t, cache, "pages.example.com",
|
storeEvictionTestSource(t, cache, "pages.example.com",
|
||||||
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
|
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
|
||||||
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)),
|
storeEvictionTestVariant(t, cache,
|
||||||
|
imagerequest.VariantKey("aabbccdd000"+strconv.Itoa(i)),
|
||||||
bytes.Repeat(content, 10))
|
bytes.Repeat(content, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +208,10 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
|||||||
coveredRows += rows
|
coveredRows += rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ten rows, each read once to check it and once to sum it.
|
// Five variant rows, each read once to check it and once to sum it,
|
||||||
if coveredRows != 20 {
|
// and five source rows, read only to sum them.
|
||||||
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
|
if coveredRows != 15 {
|
||||||
|
t.Errorf("the reads covered %d rows in all, want 15", coveredRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(ctx)
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ type heldFetcher struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *heldFetcher) Fetch(
|
func (f *heldFetcher) Fetch(
|
||||||
ctx context.Context, url string,
|
ctx context.Context, source imagerequest.Source,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
f.fetches.Add(1)
|
f.fetches.Add(1)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ func (f *heldFetcher) Fetch(
|
|||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.upstream.Fetch(ctx, url)
|
return f.upstream.Fetch(ctx, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseFetches lets every held fetch, and every later one, go on.
|
// releaseFetches lets every held fetch, and every later one, go on.
|
||||||
@@ -78,16 +79,22 @@ func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
// 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, as Get writes
|
// given quality and fit mode. Each call returns a new request, which a test
|
||||||
// to the request it is given.
|
// may change.
|
||||||
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest {
|
func photoVariant(
|
||||||
return &ImageRequest{
|
fixtures *TestFixtures, quality int, fit imagerequest.Fit,
|
||||||
SourceHost: fixtures.GoodHost,
|
) *imagerequest.Request {
|
||||||
SourcePath: testPathPhoto,
|
return &imagerequest.Request{
|
||||||
Size: Size{Width: 50, Height: 25},
|
Source: imagerequest.Source{
|
||||||
Format: FormatJPEG,
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 25},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fit,
|
FitMode: fit,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +107,7 @@ type getResult struct {
|
|||||||
// startGet calls Get in a goroutine of its own and delivers what it returned
|
// startGet calls Get in a goroutine of its own and delivers what it returned
|
||||||
// on the channel.
|
// on the channel.
|
||||||
func startGet(
|
func startGet(
|
||||||
ctx context.Context, svc *Service, req *ImageRequest,
|
ctx context.Context, svc *Service, req *imagerequest.Request,
|
||||||
) <-chan getResult {
|
) <-chan getResult {
|
||||||
results := make(chan getResult, 1)
|
results := make(chan getResult, 1)
|
||||||
|
|
||||||
@@ -146,7 +153,7 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
pending := make([]<-chan getResult, 0, requests)
|
pending := make([]<-chan getResult, 0, requests)
|
||||||
for range requests {
|
for range requests {
|
||||||
pending = append(pending,
|
pending = append(pending,
|
||||||
startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)))
|
startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover)))
|
||||||
}
|
}
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
@@ -184,9 +191,9 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
want := counterValues{0, requests, 1, int64(len(photo)), 1}
|
||||||
|
|
||||||
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
if got := readCounters(svc.cache); got != want {
|
||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,9 +206,11 @@ func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover))
|
lowQuality := startGet(t.Context(), svc,
|
||||||
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitContain))
|
photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
contain := startGet(t.Context(), svc,
|
||||||
|
photoVariant(fixtures, 85, imagerequest.FitContain))
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
select {
|
select {
|
||||||
@@ -253,12 +262,12 @@ func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
||||||
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, FitCover))
|
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
cancelWaiter()
|
cancelWaiter()
|
||||||
@@ -300,11 +309,11 @@ func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
|||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
||||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
cancelFirst()
|
cancelFirst()
|
||||||
@@ -331,8 +340,7 @@ func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
|||||||
// TestService_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
|
// 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
|
// answers all of them with its error. The request after them is answered from
|
||||||
// the negative cache when the failure is kept there, and fetches again when it
|
// the negative cache, where the failure is kept.
|
||||||
// is not.
|
|
||||||
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -346,8 +354,8 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
|||||||
{"upstream answers 404, kept in the negative cache",
|
{"upstream answers 404, kept in the negative cache",
|
||||||
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
|
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
|
||||||
ErrNegativeCached, 1},
|
ErrNegativeCached, 1},
|
||||||
{"source fails the magic byte check, not kept",
|
{"source fails the magic byte check, kept in the negative cache",
|
||||||
"/images/text.png", magic.ErrUnknownFormat, magic.ErrUnknownFormat, 2},
|
"/images/text.png", magic.ErrUnknownFormat, ErrNegativeCached, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -356,9 +364,9 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
request := func() *ImageRequest {
|
request := func() *imagerequest.Request {
|
||||||
req := photoVariant(fixtures, 85, FitCover)
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
req.SourcePath = tc.path
|
req.Source.Path = tc.path
|
||||||
|
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
@@ -400,7 +408,7 @@ func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
_, err := svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
||||||
}
|
}
|
||||||
@@ -424,7 +432,7 @@ func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case got := <-results:
|
case got := <-results:
|
||||||
@@ -447,7 +455,7 @@ func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
svc.processor = imageprocessor.New(
|
svc.processor = imageprocessor.New(
|
||||||
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
holdProcessingSlot(t, svc.processor)
|
holdProcessingSlot(t, svc.processor)
|
||||||
@@ -457,11 +465,11 @@ func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
|||||||
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
|
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
|
||||||
defer cancelFirst()
|
defer cancelFirst()
|
||||||
|
|
||||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
fetcher.releaseFetches()
|
fetcher.releaseFetches()
|
||||||
@@ -481,7 +489,7 @@ func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
|||||||
type panickingFetcher struct{}
|
type panickingFetcher struct{}
|
||||||
|
|
||||||
func (panickingFetcher) Fetch(
|
func (panickingFetcher) Fetch(
|
||||||
context.Context, string,
|
context.Context, imagerequest.Source,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
panic("upstream fetcher panicked")
|
panic("upstream fetcher panicked")
|
||||||
}
|
}
|
||||||
@@ -504,7 +512,7 @@ func TestService_Get_PanicBecomesAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
||||||
@@ -541,7 +549,7 @@ func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
events := transport.Events()
|
events := transport.Events()
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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.",
|
||||||
|
})
|
||||||
|
c.upstreamFetches = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pixa_upstream_fetches_total",
|
||||||
|
Help: "Fetches of a source image from upstream that read any bytes.",
|
||||||
|
})
|
||||||
|
c.upstreamFetchBytes = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pixa_upstream_fetch_bytes_total",
|
||||||
|
Help: "Bytes read from upstream by fetches of source images.",
|
||||||
|
})
|
||||||
|
c.transforms = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pixa_transforms_total",
|
||||||
|
Help: "Images the image processor transformed.",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, counter := range []prometheus.Counter{
|
||||||
|
c.cacheHits, c.cacheMisses, c.upstreamFetches, c.upstreamFetchBytes, c.transforms,
|
||||||
|
} {
|
||||||
|
err := metricsRegistry.Register(counter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register the cache's counters: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// counterValues holds the values of a cache's counters.
|
||||||
|
type counterValues struct {
|
||||||
|
hitCount int64
|
||||||
|
missCount int64
|
||||||
|
upstreamFetchCount int64
|
||||||
|
upstreamFetchBytes int64
|
||||||
|
transformCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCounters reads cache's counters.
|
||||||
|
func readCounters(cache *Cache) counterValues {
|
||||||
|
return counterValues{
|
||||||
|
hitCount: int64(testutil.ToFloat64(cache.cacheHits)),
|
||||||
|
missCount: int64(testutil.ToFloat64(cache.cacheMisses)),
|
||||||
|
upstreamFetchCount: int64(testutil.ToFloat64(cache.upstreamFetches)),
|
||||||
|
upstreamFetchBytes: int64(testutil.ToFloat64(cache.upstreamFetchBytes)),
|
||||||
|
transformCount: int64(testutil.ToFloat64(cache.transforms)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
CacheConfig{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",
|
||||||
|
"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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
CacheConfig{StateDir: t.TempDir(), MaxBytes: 1 << 30}, registry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCache() of the first cache error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = NewCache(evictionTestDB(t),
|
||||||
|
CacheConfig{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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-295
@@ -3,7 +3,6 @@ package imgcache
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -11,6 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultEvictionInterval is how often the background evictor checks
|
// DefaultEvictionInterval is how often the background evictor checks
|
||||||
@@ -50,27 +51,26 @@ const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
|||||||
// writes (".tmp-*" patterns in the storage layer).
|
// writes (".tmp-*" patterns in the storage layer).
|
||||||
const tempFilePrefix = ".tmp-"
|
const tempFilePrefix = ".tmp-"
|
||||||
|
|
||||||
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
|
||||||
// to each variant file.
|
|
||||||
const variantMetaSuffix = ".meta"
|
|
||||||
|
|
||||||
// fallbackContentType is the content type given to a variant file that
|
|
||||||
// has no readable .meta sidecar, when it is served or reconciled.
|
|
||||||
const fallbackContentType = "application/octet-stream"
|
|
||||||
|
|
||||||
// UsageBytes returns the total number of bytes of cache content
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
// tracked in the database (source content blobs plus processed
|
// tracked in the database, as the package function UsageBytes does, or 0
|
||||||
// variants). It reads the total the database keeps up to date as rows
|
// when the disk cache is off.
|
||||||
// are added and removed, so it neither scans the cache directories nor
|
|
||||||
// sums the tables.
|
|
||||||
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||||
if c.disabled {
|
if c.config.MaxBytes == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return UsageBytes(ctx, c.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageBytes returns the total number of bytes of cache content tracked
|
||||||
|
// in db (source content blobs plus processed variants). It reads the
|
||||||
|
// total the database keeps up to date as rows are added and removed, so
|
||||||
|
// it neither scans the cache directories nor sums the tables. pixad reads
|
||||||
|
// it before it builds the cache, to work out the default cache_max_bytes.
|
||||||
|
func UsageBytes(ctx context.Context, db *sql.DB) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
`SELECT total_size_bytes FROM cache_usage WHERE id = 1`,
|
`SELECT total_size_bytes FROM cache_usage WHERE id = 1`,
|
||||||
).Scan(&total)
|
).Scan(&total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,20 +82,20 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
// evictionCandidate is one LRU eviction victim candidate: either a
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
||||||
// processed variant (isVariant true, identified by cacheKey) or a
|
// processed variant (isVariant true, identified by cacheKey) or a
|
||||||
// source content blob (identified by contentHash).
|
// source content blob (identified by sourceKey).
|
||||||
type evictionCandidate struct {
|
type evictionCandidate struct {
|
||||||
isVariant bool
|
isVariant bool
|
||||||
cacheKey VariantKey
|
cacheKey imagerequest.VariantKey
|
||||||
contentHash ContentHash
|
sourceKey imagerequest.SourceKey
|
||||||
sizeBytes int64
|
sizeBytes int64
|
||||||
lastAccessedAt string
|
lastAccessedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvictToLimit evicts least-recently-used cache entries until total
|
// EvictToLimit evicts least-recently-used cache entries until total
|
||||||
// tracked usage is at or below the configured MaxBytes limit. It is a
|
// tracked usage is at or below the configured MaxBytes limit. It is a
|
||||||
// no-op when the cache is disabled or no limit is configured.
|
// no-op when the cache is disabled.
|
||||||
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
||||||
if c.disabled || c.config.MaxBytes <= 0 {
|
if c.config.MaxBytes == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error
|
|||||||
|
|
||||||
c.log.Warn("failed to evict cache entry",
|
c.log.Warn("failed to evict cache entry",
|
||||||
"cache_key", candidate.cacheKey,
|
"cache_key", candidate.cacheKey,
|
||||||
"content_hash", candidate.contentHash,
|
"source_key", candidate.sourceKey,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate)
|
|||||||
return c.evictVariant(ctx, candidate.cacheKey)
|
return c.evictVariant(ctx, candidate.cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.evictSourceBlob(ctx, candidate.contentHash)
|
return c.evictSourceBlob(ctx, candidate.sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictionCandidates returns up to evictionBatchSize variants and
|
// evictionCandidates returns up to evictionBatchSize variants and
|
||||||
@@ -240,7 +240,7 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
|
|||||||
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.cacheKey = VariantKey(key)
|
candidate.cacheKey = imagerequest.VariantKey(key)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,9 +257,9 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
|
|||||||
// SQLite reads the rows in order from that column's index instead of
|
// SQLite reads the rows in order from that column's index instead of
|
||||||
// sorting the whole table.
|
// sorting the whole table.
|
||||||
const sourceCandidatesQuery = `
|
const sourceCandidatesQuery = `
|
||||||
SELECT content_hash, size_bytes, last_accessed_at
|
SELECT source_key, size_bytes, last_accessed_at
|
||||||
FROM source_content
|
FROM source_content
|
||||||
ORDER BY last_accessed_at ASC, content_hash ASC
|
ORDER BY last_accessed_at ASC, source_key ASC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
|
|
||||||
// sourceCandidates returns the least recently used source blobs.
|
// sourceCandidates returns the least recently used source blobs.
|
||||||
@@ -276,14 +276,14 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate evictionCandidate
|
var candidate evictionCandidate
|
||||||
|
|
||||||
var hash string
|
var key string
|
||||||
|
|
||||||
err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.contentHash = ContentHash(hash)
|
candidate.sourceKey = imagerequest.SourceKey(key)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,19 +296,16 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evictVariant removes one variant: accounting row first, then the
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
// content and .meta files, so the database never references a deleted
|
// file, so the database never references a deleted file.
|
||||||
// file. The metaCache entry goes before the files; a GetVariant that
|
func (c *Cache) evictVariant(
|
||||||
// read them just before may put it back, and the next GetVariant then
|
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||||
// fails to open the file and removes it again.
|
) error {
|
||||||
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
|
||||||
_, err := c.db.ExecContext(ctx,
|
_, err := c.db.ExecContext(ctx,
|
||||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
err = c.variants.Delete(cacheKey)
|
err = c.variants.Delete(cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -317,117 +314,18 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceReference identifies one source_metadata row's JSON sidecar.
|
// evictSourceBlob removes one source: its row first, then its file, as
|
||||||
type sourceReference struct {
|
// evictVariant does for a variant. It holds no lock: a store of the same
|
||||||
host string
|
// source in between can leave the source's row with no file, which
|
||||||
pathHash PathHash
|
// LookupSource treats as a miss.
|
||||||
}
|
func (c *Cache) evictSourceBlob(ctx context.Context, key imagerequest.SourceKey) error {
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
// evictSourceBlob removes one source content blob. All source_metadata
|
`DELETE FROM source_content WHERE source_key = ?`, string(key))
|
||||||
// rows referencing the blob are deleted together with its
|
|
||||||
// source_content row in a single transaction BEFORE the file is
|
|
||||||
// unlinked: a blob referenced by multiple source paths is only ever
|
|
||||||
// removed together with all of its references, and database rows never
|
|
||||||
// point at deleted files. The JSON metadata sidecars for the removed
|
|
||||||
// rows are deleted afterwards.
|
|
||||||
//
|
|
||||||
// The whole operation holds the content hash's lock (the same one
|
|
||||||
// StoreSource holds for its full store), so a concurrent store of
|
|
||||||
// identical content bytes can never observe the file gone but a row
|
|
||||||
// still present, or insert a fresh row between this transaction's
|
|
||||||
// commit and the file unlink below: it either runs entirely before
|
|
||||||
// this eviction starts, or is blocked until this eviction (row
|
|
||||||
// deletion and unlink together) has fully completed.
|
|
||||||
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
|
||||||
unlock := c.contentLocks.Lock(string(contentHash))
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
references, err := c.sourceReferences(ctx, contentHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := c.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete source content row: %w", err)
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
return c.srcContent.Delete(key)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.evictSourceBlobTestHook != nil {
|
|
||||||
c.evictSourceBlobTestHook(contentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only after the rows are gone may the files be removed.
|
|
||||||
for _, reference := range references {
|
|
||||||
err := c.srcMetadata.Delete(reference.host, reference.pathHash)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to delete metadata sidecar",
|
|
||||||
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.srcContent.Delete(contentHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceReferences lists the metadata sidecar locations of every
|
|
||||||
// source_metadata row referencing the given blob.
|
|
||||||
func (c *Cache) sourceReferences(
|
|
||||||
ctx context.Context, contentHash ContentHash,
|
|
||||||
) ([]sourceReference, error) {
|
|
||||||
rows, err := c.db.QueryContext(ctx, `
|
|
||||||
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
|
||||||
`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query source references: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var references []sourceReference
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var reference sourceReference
|
|
||||||
|
|
||||||
var pathHash string
|
|
||||||
|
|
||||||
err := rows.Scan(&reference.host, &pathHash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reference.pathHash = PathHash(pathHash)
|
|
||||||
references = append(references, reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return references, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyWritePressure wakes the background evictor after a store, so
|
// notifyWritePressure wakes the background evictor after a store, so
|
||||||
@@ -435,7 +333,7 @@ func (c *Cache) sourceReferences(
|
|||||||
// storing request. The notification channel has capacity one and drops
|
// storing request. The notification channel has capacity one and drops
|
||||||
// when a wakeup is already pending.
|
// when a wakeup is already pending.
|
||||||
func (c *Cache) notifyWritePressure() {
|
func (c *Cache) notifyWritePressure() {
|
||||||
if c.disabled || c.config.MaxBytes <= 0 {
|
if c.config.MaxBytes == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,19 +480,19 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
|||||||
// as long after the last pass ended as that pass took, off the request
|
// as long after the last pass ended as that pass took, off the request
|
||||||
// hot path: it adopts variant files that predate the accounting table
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops accounting rows whose
|
// insert under transient DB contention), drops variant accounting rows
|
||||||
// files are missing, removes source blob files the database does not
|
// whose files are missing, removes source blob files the database does
|
||||||
// know (and rows whose files are gone), removes source and variant files
|
// not know, removes source and variant files that are empty or not the
|
||||||
// that are empty or not the size their rows record, sweeps stale temp
|
// size their rows record, sweeps stale temp files left behind by crashed
|
||||||
// files left behind by crashed writes, and last checks the total cache
|
// writes, and last checks the total cache usage against the tables. It
|
||||||
// usage against the tables. It reads the tables a page at a time.
|
// reads the tables a page at a time.
|
||||||
// Running it periodically, not just once, bounds how long such drift can
|
// Running it periodically, not just once, bounds how long such drift can
|
||||||
// accumulate unaccounted for on a long-running process to about one
|
// accumulate unaccounted for on a long-running process to about one
|
||||||
// eviction interval plus ten times the last pass's time.
|
// eviction interval plus ten times the last pass's time.
|
||||||
// Once ctx is cancelled, it stops at the next file or row and returns
|
// Once ctx is cancelled, it stops at the next file or row and returns
|
||||||
// ctx's error.
|
// ctx's error.
|
||||||
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
if c.disabled {
|
if c.config.MaxBytes == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,20 +511,19 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.reconcileSourceRows(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.reconcileUsageTotal(ctx)
|
return c.reconcileUsageTotal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileVariantFiles walks the variant storage directory, checking
|
// reconcileFiles walks baseDir, one of the cache's storage directories,
|
||||||
// each variant file against its accounting row (reconcileVariantFile)
|
// sweeping stale temp files and passing each other file, with its name, to
|
||||||
// and sweeping stale temp files.
|
// reconcileFile. Once ctx is cancelled it stops at the next file and
|
||||||
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
// returns ctx's error.
|
||||||
|
func (c *Cache) reconcileFiles(
|
||||||
|
ctx context.Context, baseDir string,
|
||||||
|
reconcileFile func(path string, entry fs.DirEntry, name string) error,
|
||||||
|
) error {
|
||||||
return filepath.WalkDir(
|
return filepath.WalkDir(
|
||||||
c.variants.baseDir,
|
baseDir,
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -644,22 +541,28 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(name, variantMetaSuffix) {
|
return reconcileFile(path, entry, name)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.reconcileVariantFile(ctx, path, entry, VariantKey(name))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileVariantFiles walks the variant storage directory, checking
|
||||||
|
// each variant file against its accounting row (reconcileVariantFile)
|
||||||
|
// and sweeping stale temp files.
|
||||||
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
|
return c.reconcileFiles(ctx, c.variants.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, name string) error {
|
||||||
|
return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// reconcileVariantFile checks one variant file against its accounting
|
// reconcileVariantFile checks one variant file against its accounting
|
||||||
// row. A file without a row is adopted into the accounting, unless it is
|
// row. A file without a row is adopted into the accounting, unless it is
|
||||||
// empty. A file that is empty, or not the size its row records, as a
|
// empty. A file that is empty, or not the size its row records, as a
|
||||||
// crash or a restored snapshot can leave it, is removed with its row and
|
// crash or a restored snapshot can leave it, is removed with its row and
|
||||||
// a warning naming it, so the variant is converted again.
|
// a warning naming it, so the variant is converted again.
|
||||||
func (c *Cache) reconcileVariantFile(
|
func (c *Cache) reconcileVariantFile(
|
||||||
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey imagerequest.VariantKey,
|
||||||
) error {
|
) error {
|
||||||
info, err := entry.Info()
|
info, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -674,7 +577,7 @@ func (c *Cache) reconcileVariantFile(
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
||||||
return c.adoptVariantFile(ctx, path, info, cacheKey)
|
return c.adoptVariantFile(ctx, info, cacheKey)
|
||||||
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||||
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
case info.Size() > 0 && info.Size() == recordedSize:
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
@@ -691,16 +594,14 @@ func (c *Cache) reconcileVariantFile(
|
|||||||
// adoptVariantFile inserts an accounting row for a variant file that
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
// has none, using the file's size and modification time.
|
// has none, using the file's size and modification time.
|
||||||
func (c *Cache) adoptVariantFile(
|
func (c *Cache) adoptVariantFile(
|
||||||
ctx context.Context, path string, info fs.FileInfo, cacheKey VariantKey,
|
ctx context.Context, info fs.FileInfo, cacheKey imagerequest.VariantKey,
|
||||||
) error {
|
) error {
|
||||||
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
contentType := c.variantContentTypeFromSidecar(path)
|
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content
|
INSERT INTO variant_content (cache_key, size_bytes, last_accessed_at)
|
||||||
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
VALUES (?, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
`, string(cacheKey), info.Size(), modTime)
|
||||||
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
||||||
}
|
}
|
||||||
@@ -711,27 +612,10 @@ func (c *Cache) adoptVariantFile(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// variantContentTypeFromSidecar reads the content type from a variant
|
|
||||||
// .meta sidecar, falling back to application/octet-stream.
|
|
||||||
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
|
||||||
//nolint:gosec // path from cache walk
|
|
||||||
metaData, err := os.ReadFile(variantPath + variantMetaSuffix)
|
|
||||||
if err != nil {
|
|
||||||
return fallbackContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
var meta VariantMeta
|
|
||||||
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
|
||||||
return fallbackContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta.ContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileVariantRows drops accounting rows whose variant files are
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
// missing, so the database never references deleted content.
|
// missing, so the database never references deleted content.
|
||||||
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
var after VariantKey
|
var after imagerequest.VariantKey
|
||||||
|
|
||||||
for {
|
for {
|
||||||
keys, err := c.variantKeysAfter(ctx, after)
|
keys, err := c.variantKeysAfter(ctx, after)
|
||||||
@@ -772,9 +656,9 @@ func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
|||||||
// variantKeysAfter returns, in order, up to c.reconciliationPageSize
|
// variantKeysAfter returns, in order, up to c.reconciliationPageSize
|
||||||
// tracked variant cache keys that sort after the given one.
|
// tracked variant cache keys that sort after the given one.
|
||||||
func (c *Cache) variantKeysAfter(
|
func (c *Cache) variantKeysAfter(
|
||||||
ctx context.Context, after VariantKey,
|
ctx context.Context, after imagerequest.VariantKey,
|
||||||
) ([]VariantKey, error) {
|
) ([]imagerequest.VariantKey, error) {
|
||||||
return queryStringColumn[VariantKey](ctx, c.db, `
|
return queryStringColumn[imagerequest.VariantKey](ctx, c.db, `
|
||||||
SELECT cache_key FROM variant_content
|
SELECT cache_key FROM variant_content
|
||||||
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
|
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
|
||||||
`, "variant keys", "variant key", string(after), c.reconciliationPageSize)
|
`, "variant keys", "variant key", string(after), c.reconciliationPageSize)
|
||||||
@@ -819,38 +703,20 @@ func queryStringColumn[T ~string](
|
|||||||
// each blob file against its source_content row (reconcileSourceFile)
|
// each blob file against its source_content row (reconcileSourceFile)
|
||||||
// and sweeping stale temp files.
|
// and sweeping stale temp files.
|
||||||
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
return filepath.WalkDir(
|
return c.reconcileFiles(ctx, c.srcContent.baseDir,
|
||||||
c.srcContent.baseDir,
|
func(path string, entry fs.DirEntry, name string) error {
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
return c.reconcileSourceFile(ctx, path, entry, imagerequest.SourceKey(name))
|
||||||
if ctx.Err() != nil {
|
})
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || entry.IsDir() {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
|
|
||||||
if strings.HasPrefix(name, tempFilePrefix) {
|
|
||||||
c.sweepStaleTempFile(path, entry)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.reconcileSourceFile(ctx, path, entry, ContentHash(name))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceFile checks one source blob file against its
|
// reconcileSourceFile checks one source blob file against its
|
||||||
// source_content row. A file without a row is removed: it is
|
// source_content row. A file without a row is removed, as no lookup
|
||||||
// unreachable, as source lookups always go through source_metadata. A
|
// serves it and no eviction removes it. A file that is empty, or not the
|
||||||
// file that is empty, or not the size its row records, as a crash or a
|
// size its row records, as a crash or a restored snapshot can leave it,
|
||||||
// restored snapshot can leave it, is removed with its rows and a warning
|
// is removed with its row and a warning naming it, so the source is
|
||||||
// naming it, so the source is fetched again.
|
// fetched again.
|
||||||
func (c *Cache) reconcileSourceFile(
|
func (c *Cache) reconcileSourceFile(
|
||||||
ctx context.Context, path string, entry fs.DirEntry, contentHash ContentHash,
|
ctx context.Context, path string, entry fs.DirEntry, key imagerequest.SourceKey,
|
||||||
) error {
|
) error {
|
||||||
info, err := entry.Info()
|
info, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -860,13 +726,13 @@ func (c *Cache) reconcileSourceFile(
|
|||||||
var recordedSize int64
|
var recordedSize int64
|
||||||
|
|
||||||
err = c.db.QueryRowContext(ctx,
|
err = c.db.QueryRowContext(ctx,
|
||||||
`SELECT size_bytes FROM source_content WHERE content_hash = ?`,
|
`SELECT size_bytes FROM source_content WHERE source_key = ?`,
|
||||||
string(contentHash),
|
string(key),
|
||||||
).Scan(&recordedSize)
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
return c.removeUntrackedSourceFile(ctx, path, contentHash)
|
return c.removeUntrackedSourceFile(path, key)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("failed to check source content row: %w", err)
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
case info.Size() > 0 && info.Size() == recordedSize:
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
@@ -877,96 +743,34 @@ func (c *Cache) reconcileSourceFile(
|
|||||||
c.log.Warn("removing damaged cached source file", "path", path,
|
c.log.Warn("removing damaged cached source file", "path", path,
|
||||||
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
return c.evictSourceBlob(ctx, contentHash)
|
return c.evictSourceBlob(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeUntrackedSourceFile deletes a source blob file that has no
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
// source_content row. Any source_metadata rows referencing the hash
|
// source_content row.
|
||||||
// are removed first so no row ever points at a deleted file.
|
|
||||||
func (c *Cache) removeUntrackedSourceFile(
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
ctx context.Context, path string, contentHash ContentHash,
|
path string, key imagerequest.SourceKey,
|
||||||
) error {
|
) error {
|
||||||
_, err := c.db.ExecContext(ctx,
|
err := os.Remove(path)
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Remove(path)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
c.log.Info("removed untracked source content file", "source_key", key)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceRows removes source_content rows (and their metadata
|
|
||||||
// references and sidecars) whose blob files are missing on disk.
|
|
||||||
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
|
||||||
var after ContentHash
|
|
||||||
|
|
||||||
for {
|
|
||||||
hashes, err := c.sourceContentHashesAfter(ctx, after)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.reconciliationReadTestHook != nil {
|
|
||||||
c.reconciliationReadTestHook(len(hashes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hashes) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range hashes {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.srcContent.Exists(hash) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The blob file is already gone; evictSourceBlob removes the
|
|
||||||
// rows and sidecars and tolerates the missing file.
|
|
||||||
err := c.evictSourceBlob(ctx, hash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
after = hashes[len(hashes)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceContentHashesAfter returns, in order, up to
|
|
||||||
// c.reconciliationPageSize tracked source content hashes that sort after
|
|
||||||
// the given one.
|
|
||||||
func (c *Cache) sourceContentHashesAfter(
|
|
||||||
ctx context.Context, after ContentHash,
|
|
||||||
) ([]ContentHash, error) {
|
|
||||||
return queryStringColumn[ContentHash](ctx, c.db, `
|
|
||||||
SELECT content_hash FROM source_content
|
|
||||||
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
|
||||||
`, "source content hashes", "content hash", string(after),
|
|
||||||
c.reconciliationPageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each of these queries sums size_bytes over the next page of rows of
|
// Each of these queries sums size_bytes over the next page of rows of
|
||||||
// one content table, the rows that sort after a key, and returns the
|
// one content table, the rows that sort after a key, and returns the
|
||||||
// page's last key, the sum and the number of rows in the page. Past the
|
// page's last key, the sum and the number of rows in the page. Past the
|
||||||
// last row the page has no rows and the key is NULL.
|
// last row the page has no rows and the key is NULL.
|
||||||
const (
|
const (
|
||||||
sourceSizePageQuery = `
|
sourceSizePageQuery = `
|
||||||
SELECT MAX(content_hash), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
SELECT MAX(source_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT content_hash, size_bytes FROM source_content
|
SELECT source_key, size_bytes FROM source_content
|
||||||
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
WHERE source_key > ? ORDER BY source_key LIMIT ?
|
||||||
)`
|
)`
|
||||||
variantSizePageQuery = `
|
variantSizePageQuery = `
|
||||||
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
||||||
@@ -27,10 +29,10 @@ const sqliteTimestampFormat = "2006-01-02 15:04:05"
|
|||||||
// keys reused across the eviction tests, in the order the tests store
|
// keys reused across the eviction tests, in the order the tests store
|
||||||
// them.
|
// them.
|
||||||
const (
|
const (
|
||||||
testVariantKeyOne VariantKey = "aabbccdd0001"
|
testVariantKeyOne imagerequest.VariantKey = "aabbccdd0001"
|
||||||
testVariantKeyTwo VariantKey = "aabbccdd0002"
|
testVariantKeyTwo imagerequest.VariantKey = "aabbccdd0002"
|
||||||
testVariantKeyThree VariantKey = "aabbccdd0003"
|
testVariantKeyThree imagerequest.VariantKey = "aabbccdd0003"
|
||||||
testVariantKeyFour VariantKey = "aabbccdd0004"
|
testVariantKeyFour imagerequest.VariantKey = "aabbccdd0004"
|
||||||
)
|
)
|
||||||
|
|
||||||
// evictionTestDB creates an in-memory SQLite database with the real
|
// evictionTestDB creates an in-memory SQLite database with the real
|
||||||
@@ -64,16 +66,12 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := evictionTestDB(t)
|
db := evictionTestDB(t)
|
||||||
|
|
||||||
// maxBytes zero mirrors the production mapping of
|
// maxBytes zero turns the disk cache off, as cache_max_bytes: 0 does.
|
||||||
// cache_max_bytes: 0 (handlers sets DisableDiskCache); at the
|
|
||||||
// CacheConfig layer itself a zero MaxBytes means "no limit" for
|
|
||||||
// backwards compatibility with existing fixtures.
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
MaxBytes: maxBytes,
|
MaxBytes: maxBytes,
|
||||||
DisableDiskCache: maxBytes == 0,
|
}, prometheus.NewRegistry())
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,47 +80,42 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestSource stores content as a fetched source for
|
// storeEvictionTestSource stores content as a fetched source for
|
||||||
// host/path and returns the resulting content hash.
|
// host/path and returns the resulting source key.
|
||||||
func storeEvictionTestSource(
|
func storeEvictionTestSource(
|
||||||
t *testing.T, cache *Cache, host, path string, content []byte,
|
t *testing.T, cache *Cache, host, path string, content []byte,
|
||||||
) ContentHash {
|
) imagerequest.SourceKey {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &imagerequest.Request{
|
||||||
SourceHost: host,
|
Source: imagerequest.Source{
|
||||||
SourcePath: path,
|
Host: host,
|
||||||
Format: FormatJPEG,
|
Path: path,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
|
||||||
|
|
||||||
result := &httpfetcher.FetchResult{
|
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: int64(len(content)),
|
|
||||||
Headers: map[string][]string{
|
|
||||||
testHeaderContentType: {testContentTypeJPEG},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := cache.StoreSource(
|
key, err := cache.StoreSource(
|
||||||
context.Background(), req, bytes.NewReader(content), result,
|
context.Background(), req, bytes.NewReader(content),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestVariant stores content as a processed variant under
|
// storeEvictionTestVariant stores content as a processed variant under
|
||||||
// the given cache key.
|
// the given cache key.
|
||||||
func storeEvictionTestVariant(
|
func storeEvictionTestVariant(
|
||||||
t *testing.T, cache *Cache, key VariantKey, content []byte,
|
t *testing.T, cache *Cache, key imagerequest.VariantKey, content []byte,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
err := cache.StoreVariant(t.Context(), key, bytes.NewReader(content), "image/webp")
|
err := cache.StoreVariant(t.Context(), key, bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
|
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
|
||||||
}
|
}
|
||||||
@@ -131,7 +124,7 @@ func storeEvictionTestVariant(
|
|||||||
// setVariantLastAccessed backdates the last access time of a tracked
|
// setVariantLastAccessed backdates the last access time of a tracked
|
||||||
// variant, to make LRU ordering deterministic in tests.
|
// variant, to make LRU ordering deterministic in tests.
|
||||||
func setVariantLastAccessed(
|
func setVariantLastAccessed(
|
||||||
t *testing.T, cache *Cache, key VariantKey, when time.Time,
|
t *testing.T, cache *Cache, key imagerequest.VariantKey, when time.Time,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -157,13 +150,13 @@ func setVariantLastAccessed(
|
|||||||
// setSourceLastAccessed backdates the last access time of a tracked
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
// source content blob.
|
// source content blob.
|
||||||
func setSourceLastAccessed(
|
func setSourceLastAccessed(
|
||||||
t *testing.T, cache *Cache, hash ContentHash, when time.Time,
|
t *testing.T, cache *Cache, key imagerequest.SourceKey, when time.Time,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := cache.db.ExecContext(t.Context(),
|
res, err := cache.db.ExecContext(t.Context(),
|
||||||
`UPDATE source_content SET last_accessed_at = ? WHERE content_hash = ?`,
|
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
||||||
when.UTC().Format(sqliteTimestampFormat), string(hash),
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
@@ -175,7 +168,7 @@ func setSourceLastAccessed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if affected != 1 {
|
if affected != 1 {
|
||||||
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
|
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,32 +193,31 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rows, err := cache.db.QueryContext(t.Context(),
|
rows, err := cache.db.QueryContext(t.Context(),
|
||||||
`SELECT content_hash FROM source_metadata
|
`SELECT source_key FROM source_content`,
|
||||||
WHERE content_hash IS NOT NULL AND content_hash != ''`,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query source_metadata: %v", err)
|
t.Fatalf("failed to query source_content: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var hash string
|
var key string
|
||||||
|
|
||||||
err := rows.Scan(&hash)
|
err := rows.Scan(&key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to scan content_hash: %v", err)
|
t.Fatalf("failed to scan source_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(ContentHash(hash)) {
|
if !cache.srcContent.Exists(imagerequest.SourceKey(key)) {
|
||||||
t.Errorf("source_metadata references content %s but the file is missing",
|
t.Errorf("source_content references content %s but the file is missing",
|
||||||
hash)
|
key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("source_metadata iteration failed: %v", err)
|
t.Fatalf("source_content iteration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variantRows, err := cache.db.QueryContext(t.Context(),
|
variantRows, err := cache.db.QueryContext(t.Context(),
|
||||||
@@ -244,7 +236,7 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
|||||||
t.Fatalf("failed to scan cache_key: %v", err)
|
t.Fatalf("failed to scan cache_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.variants.Exists(VariantKey(key)) {
|
if !cache.variants.Exists(imagerequest.VariantKey(key)) {
|
||||||
t.Errorf("variant_content references key %s but the file is missing", key)
|
t.Errorf("variant_content references key %s but the file is missing", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,33 +300,6 @@ func TestUsageBytesAccountsSourceAndVariantBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUsageBytesCountsMultiReferencedBlobOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xCC}, 1200)
|
|
||||||
|
|
||||||
hashOne := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/one.jpg", content)
|
|
||||||
hashTwo := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/two.jpg", content)
|
|
||||||
|
|
||||||
if hashOne != hashTwo {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s",
|
|
||||||
hashOne, hashTwo)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage != 1200 {
|
|
||||||
t.Errorf("UsageBytes = %d, want 1200 (deduplicated blob counted once)", usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -343,7 +308,7 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
|||||||
cache, _ := newEvictionTestCache(t, limit)
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
keys := []VariantKey{
|
keys := []imagerequest.VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo,
|
testVariantKeyOne, testVariantKeyTwo,
|
||||||
testVariantKeyThree, testVariantKeyFour,
|
testVariantKeyThree, testVariantKeyFour,
|
||||||
}
|
}
|
||||||
@@ -388,102 +353,15 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 1000
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// One 800-byte blob referenced by two source paths.
|
|
||||||
sharedContent := bytes.Repeat([]byte{0xDD}, 800)
|
|
||||||
sharedHash := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/a.jpg", sharedContent)
|
|
||||||
|
|
||||||
h := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/b.jpg", sharedContent)
|
|
||||||
if h != sharedHash {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s",
|
|
||||||
h, sharedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A newer 600-byte blob referenced by one source path.
|
|
||||||
recentHash := storeEvictionTestSource(t, cache, "src.example.com", "/c.jpg",
|
|
||||||
bytes.Repeat([]byte{0xEE}, 600))
|
|
||||||
|
|
||||||
setSourceLastAccessed(t, cache, sharedHash, now.Add(-2*time.Hour))
|
|
||||||
setSourceLastAccessed(t, cache, recentHash, now.Add(-time.Minute))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage > limit {
|
|
||||||
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The multi-referenced blob must be gone from disk, from
|
|
||||||
// source_content, and from BOTH source_metadata rows: references
|
|
||||||
// are removed together with the blob, never left dangling.
|
|
||||||
if cache.srcContent.Exists(sharedHash) {
|
|
||||||
t.Errorf("evicted blob %s still exists on disk", sharedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(sharedHash),
|
|
||||||
); n != 0 {
|
|
||||||
t.Errorf("evicted blob %s still has %d source_content rows", sharedHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(sharedHash),
|
|
||||||
); n != 0 {
|
|
||||||
t.Errorf("evicted blob %s still has %d source_metadata references", sharedHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The JSON metadata sidecars for both referencing paths must be
|
|
||||||
// removed along with the rows.
|
|
||||||
for _, path := range []string{"/a.jpg", "/b.jpg"} {
|
|
||||||
pathHash := HashPath(path + "?")
|
|
||||||
if cache.srcMetadata.Exists("src.example.com", pathHash) {
|
|
||||||
t.Errorf("metadata sidecar for %s must be removed with its row", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The more recently used blob survives fully intact.
|
|
||||||
if !cache.srcContent.Exists(recentHash) {
|
|
||||||
t.Errorf("recently used blob %s must survive eviction", recentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(recentHash),
|
|
||||||
); n != 1 {
|
|
||||||
t.Errorf("recently used blob %s has %d source_metadata rows, want 1",
|
|
||||||
recentHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xDF}, 800)
|
content := bytes.Repeat([]byte{0xDF}, 800)
|
||||||
hash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content)
|
keys := []imagerequest.SourceKey{
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content),
|
||||||
h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content)
|
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content),
|
||||||
if h != hash {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s", h, hash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
@@ -494,14 +372,16 @@ func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
|||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(hash) {
|
for _, key := range keys {
|
||||||
t.Errorf("blob %s must not be evicted while usage is under the limit", hash)
|
if !cache.srcContent.Exists(key) {
|
||||||
|
t.Errorf("source %s must not be evicted while usage is under the limit", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
if n := countRows(t, cache,
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(hash),
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
||||||
); n != 2 {
|
); n != 1 {
|
||||||
t.Errorf("blob %s has %d source_metadata rows, want 2", hash, n)
|
t.Errorf("source %s has %d source_content rows, want 1", key, n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.variants.Exists(testVariantKeyOne) {
|
if !cache.variants.Exists(testVariantKeyOne) {
|
||||||
@@ -516,12 +396,16 @@ func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
|||||||
|
|
||||||
cache, tmpDir := newEvictionTestCache(t, 0)
|
cache, tmpDir := newEvictionTestCache(t, 0)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &imagerequest.Request{
|
||||||
SourceHost: "src.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/a.jpg",
|
Host: "src.example.com",
|
||||||
Format: FormatJPEG,
|
Path: "/a.jpg",
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
assertDisabledCacheWritesAreNoOps(t, cache, req)
|
assertDisabledCacheWritesAreNoOps(t, cache, req)
|
||||||
@@ -533,62 +417,45 @@ func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
|||||||
// assertDisabledCacheWritesAreNoOps verifies that stores against a
|
// assertDisabledCacheWritesAreNoOps verifies that stores against a
|
||||||
// disabled cache report success without recording anything.
|
// disabled cache report success without recording anything.
|
||||||
func assertDisabledCacheWritesAreNoOps(
|
func assertDisabledCacheWritesAreNoOps(
|
||||||
t *testing.T, cache *Cache, req *ImageRequest,
|
t *testing.T, cache *Cache, req *imagerequest.Request,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
err := cache.StoreVariant(
|
||||||
ctx, CacheKey(req), bytes.NewReader([]byte("data")), "image/webp",
|
ctx, imagerequest.CacheKey(req), bytes.NewReader([]byte("data")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &httpfetcher.FetchResult{
|
key, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")))
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: 4,
|
|
||||||
Headers: map[string][]string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash != "" {
|
if key != "" {
|
||||||
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
|
t.Errorf("StoreSource on disabled cache returned key %q, want empty", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertDisabledCacheReadsAlwaysMiss verifies that lookups against a
|
// assertDisabledCacheReadsAlwaysMiss verifies that lookups against a
|
||||||
// disabled cache never report a hit.
|
// disabled cache never report a hit.
|
||||||
func assertDisabledCacheReadsAlwaysMiss(
|
func assertDisabledCacheReadsAlwaysMiss(
|
||||||
t *testing.T, cache *Cache, req *ImageRequest,
|
t *testing.T, cache *Cache, req *imagerequest.Request,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
lookup, err := cache.Lookup(ctx, req)
|
key, err := cache.LookupSource(ctx, req)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup on disabled cache failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lookup.Hit {
|
|
||||||
t.Error("Lookup on disabled cache must always miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
srcHash, srcType, err := cache.LookupSource(ctx, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcHash != "" || srcType != "" {
|
if key != "" {
|
||||||
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
t.Errorf("LookupSource on disabled cache = %q, want empty", key)
|
||||||
srcHash, srcType)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,10 +476,6 @@ func assertDisabledCacheTracksNothing(t *testing.T, cache *Cache) {
|
|||||||
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
||||||
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_metadata`); n != 0 {
|
|
||||||
t.Errorf("disabled cache wrote %d source_metadata rows, want 0", n)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
||||||
@@ -661,7 +524,7 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
|||||||
cache.StartEviction(time.Hour)
|
cache.StartEviction(time.Hour)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []imagerequest.VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
fills := []byte{0x11, 0x12, 0x13}
|
fills := []byte{0x11, 0x12, 0x13}
|
||||||
@@ -715,7 +578,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []imagerequest.VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
fills := []byte{0x21, 0x22, 0x23}
|
fills := []byte{0x21, 0x22, 0x23}
|
||||||
@@ -723,7 +586,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
_, err = cache.variants.Store(key, bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store variant file: %v", err)
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -775,7 +638,7 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
|||||||
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
||||||
|
|
||||||
_, err := cache.variants.Store(
|
_, err := cache.variants.Store(
|
||||||
testVariantKeyOne, bytes.NewReader(untracked), "image/webp",
|
testVariantKeyOne, bytes.NewReader(untracked),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
@@ -783,9 +646,8 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
|||||||
|
|
||||||
// An accounting row whose file is missing must be dropped.
|
// An accounting row whose file is missing must be dropped.
|
||||||
_, err = cache.db.ExecContext(t.Context(),
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
`INSERT INTO variant_content (cache_key, size_bytes) VALUES (?, ?)`,
|
||||||
VALUES (?, ?, ?)`,
|
"deadbeef0001", 700,
|
||||||
"deadbeef0001", 700, "image/webp",
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert stale variant accounting row: %v", err)
|
t.Fatalf("failed to insert stale variant accounting row: %v", err)
|
||||||
@@ -877,7 +739,7 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
untracked := bytes.Repeat([]byte{0x41}, 900)
|
untracked := bytes.Repeat([]byte{0x41}, 900)
|
||||||
|
|
||||||
_, err = cache.variants.Store(
|
_, err = cache.variants.Store(
|
||||||
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
"aabbccdd0099", bytes.NewReader(untracked),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
@@ -1055,9 +917,15 @@ func TestStopEvictionInterruptsPassInProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
||||||
// while the oldest of three source blobs is being evicted, and checks that
|
// once the oldest of three sources is evicted, while the next one waits
|
||||||
// EvictToLimit then returns context.Canceled without evicting the other
|
// for the database, and checks that EvictToLimit then returns
|
||||||
// two or logging a warning for either of them.
|
// context.Canceled, with that source gone and the other two kept, and
|
||||||
|
// logs no warning. The test database has one connection. When the
|
||||||
|
// deletion of the oldest source's row commits, a commit hook holds the
|
||||||
|
// deletion until the test waits for the connection, so the test takes it
|
||||||
|
// as soon as the deletion is done. The eviction of the next source then
|
||||||
|
// waits for the connection, and the test cancels the context while it
|
||||||
|
// waits.
|
||||||
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1067,7 +935,7 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|||||||
|
|
||||||
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||||
|
|
||||||
hashes := []ContentHash{
|
keys := []imagerequest.SourceKey{
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
||||||
bytes.Repeat([]byte{0x61}, 1000)),
|
bytes.Repeat([]byte{0x61}, 1000)),
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
||||||
@@ -1078,69 +946,155 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|||||||
|
|
||||||
base := time.Now().Add(-time.Hour)
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
for i, hash := range hashes {
|
for i, key := range keys {
|
||||||
setSourceLastAccessed(t, cache, hash, base.Add(time.Duration(i)*time.Minute))
|
setSourceLastAccessed(t, cache, key, base.Add(time.Duration(i)*time.Minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
oldestRowDeleted := make(chan struct{})
|
||||||
|
|
||||||
|
var oldestRowDeletedOnce sync.Once
|
||||||
|
|
||||||
|
setCommitHook(t, cache, func() int32 {
|
||||||
|
oldestRowDeletedOnce.Do(func() {
|
||||||
|
close(oldestRowDeleted)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for cache.db.Stats().WaitCount == waitsBefore && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(ContentHash) { cancel() }
|
evicted := make(chan error, 1)
|
||||||
|
|
||||||
err := cache.EvictToLimit(ctx)
|
go func() { evicted <- cache.EvictToLimit(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldestRowDeleted:
|
||||||
|
case err := <-evicted:
|
||||||
|
t.Fatalf("EvictToLimit() returned %v without deleting a row", err)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("EvictToLimit never deleted a row")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+2)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := <-evicted
|
||||||
t.Logf("EvictToLimit() error = %v", err)
|
t.Logf("EvictToLimit() error = %v", err)
|
||||||
t.Logf("log output: %s", logBuf.String())
|
t.Logf("log output: %s", logBuf.String())
|
||||||
|
|
||||||
|
// The checks below read the database, so the connection goes back.
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
setCommitHook(t, cache, nil)
|
||||||
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.srcContent.Exists(hashes[0]) {
|
assertOnlyOldestSourceEvicted(t, cache, keys)
|
||||||
t.Errorf("source blob %s, evicted when the context was cancelled, "+
|
|
||||||
"is still on disk", hashes[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range hashes[1:] {
|
|
||||||
if !cache.srcContent.Exists(hash) {
|
|
||||||
t.Errorf("source blob %s was evicted after the context was cancelled", hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
||||||
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCommitHook makes the SQLite driver call hook each time a write on
|
||||||
|
// the test database's one connection commits, or stops it when hook is
|
||||||
|
// nil.
|
||||||
|
func setCommitHook(t *testing.T, cache *Cache, hook sqlite.CommitHookFn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
err = conn.Raw(func(driverConn any) error {
|
||||||
|
hooks, ok := driverConn.(sqlite.HookRegisterer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("the database connection is a %T, which takes no hooks",
|
||||||
|
driverConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.RegisterCommitHook(hook)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set the commit hook: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertOnlyOldestSourceEvicted checks that the first of keys has neither
|
||||||
|
// its file nor its row, and that every other one has both.
|
||||||
|
func assertOnlyOldestSourceEvicted(
|
||||||
|
t *testing.T, cache *Cache, keys []imagerequest.SourceKey,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cache.srcContent.Exists(keys[0]) {
|
||||||
|
t.Errorf("source %s, evicted when the context was cancelled, "+
|
||||||
|
"is still on disk", keys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys[1:] {
|
||||||
|
if !cache.srcContent.Exists(key) {
|
||||||
|
t.Errorf("source %s was evicted after the context was cancelled", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("source %s, kept, has %d source_content rows, want 1", key, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopEvictionReturnsWhenItsContextEnds pauses an eviction pass where
|
// TestStopEvictionReturnsWhenItsContextEnds pauses the startup
|
||||||
// cancellation cannot reach it, after a source blob's rows are deleted and
|
// reconciliation pass in the hook called after each of its reads, where
|
||||||
// before its file is removed, and checks that StopEviction returns its
|
// cancellation cannot reach it, and checks that StopEviction returns an
|
||||||
// context's error when that context ends instead of waiting for the pass.
|
// error wrapping its context's error when that context ends, instead of
|
||||||
// Once the pass goes on, the goroutine exits and no row points at a
|
// waiting for the pass. Once the pass goes on, the goroutine exits, and a
|
||||||
// missing file.
|
// second StopEviction returns nil.
|
||||||
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
paused := make(chan struct{})
|
paused := make(chan struct{})
|
||||||
resume := make(chan struct{})
|
resume := make(chan struct{})
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(ContentHash) {
|
var pauseOnce sync.Once
|
||||||
|
|
||||||
|
cache.reconciliationReadTestHook = func(int) {
|
||||||
|
pauseOnce.Do(func() {
|
||||||
close(paused)
|
close(paused)
|
||||||
<-resume
|
<-resume
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := storeEvictionTestSource(t, cache, "stop.example.com", "/a.jpg",
|
|
||||||
bytes.Repeat([]byte{0x61}, 1000))
|
|
||||||
|
|
||||||
cache.StartEviction(time.Hour)
|
cache.StartEviction(time.Hour)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-paused:
|
case <-paused:
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("the eviction pass never reached the source blob")
|
t.Fatal("the reconciliation pass never made a read")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||||
@@ -1159,12 +1113,6 @@ func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
|
|
||||||
if cache.srcContent.Exists(hash) {
|
|
||||||
t.Errorf("source blob %s is still on disk after its rows were deleted", hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
||||||
@@ -1236,125 +1184,3 @@ func TestReconciliationPassLogsNoWarningOnceCancelled(t *testing.T) {
|
|||||||
t.Errorf("runReconciliationPass logged a warning with a cancelled context")
|
t.Errorf("runReconciliationPass logged a warning with a cancelled context")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent exercises
|
|
||||||
// the exact TOCTOU window between evictSourceBlob's row-deletion
|
|
||||||
// transaction commit and its content file unlink: a concurrent
|
|
||||||
// StoreSource for a different source path whose content hashes to the
|
|
||||||
// same value (real SHA-256 dedup, not a contrived case) must not be
|
|
||||||
// able to insert a fresh row referencing the file while eviction is
|
|
||||||
// mid-unlink, and must not lose its own store once eviction has fully
|
|
||||||
// released the content hash.
|
|
||||||
func TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0x55}, 400)
|
|
||||||
|
|
||||||
hash := storeEvictionTestSource(t, cache,
|
|
||||||
"race.example.com", "/first.jpg", content)
|
|
||||||
|
|
||||||
proceed := make(chan struct{})
|
|
||||||
storeAttempted := make(chan struct{})
|
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(gotHash ContentHash) {
|
|
||||||
if gotHash != hash {
|
|
||||||
t.Errorf("test hook invoked for hash %s, want %s", gotHash, hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(storeAttempted)
|
|
||||||
<-proceed
|
|
||||||
}
|
|
||||||
|
|
||||||
evictDone := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
evictDone <- cache.evictSourceBlob(ctx, hash)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait until eviction has committed its delete transaction and is
|
|
||||||
// paused (inside the test hook) immediately before unlinking the
|
|
||||||
// content file: exactly the window the review flagged.
|
|
||||||
<-storeAttempted
|
|
||||||
|
|
||||||
storeDone := storeIdenticalContentConcurrently(ctx, cache, content)
|
|
||||||
|
|
||||||
// The concurrent store must not be able to complete while eviction
|
|
||||||
// still holds the content hash (i.e. before the file is unlinked):
|
|
||||||
// if it could, it would insert a row referencing a file about to be
|
|
||||||
// removed out from under it.
|
|
||||||
select {
|
|
||||||
case err := <-storeDone:
|
|
||||||
t.Fatalf("StoreSource for identical content completed (err=%v) while eviction "+
|
|
||||||
"still held the content hash open between commit and unlink; the store and "+
|
|
||||||
"the evict of identical content are not mutually exclusive", err)
|
|
||||||
case <-time.After(200 * time.Millisecond):
|
|
||||||
// Expected: the store is blocked behind eviction's exclusion.
|
|
||||||
}
|
|
||||||
|
|
||||||
close(proceed)
|
|
||||||
|
|
||||||
err := <-evictDone
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evictSourceBlob failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = <-storeDone
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreSource failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
|
|
||||||
dupHash, _, err := cache.LookupSource(ctx, &ImageRequest{
|
|
||||||
SourceHost: "race.example.com",
|
|
||||||
SourcePath: "/dup.jpg",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LookupSource failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dupHash == "" {
|
|
||||||
t.Fatal("re-stored blob was lost: the store legitimately ran after eviction " +
|
|
||||||
"released the content hash and must have recreated the file and row")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cache.srcContent.Exists(dupHash) {
|
|
||||||
t.Errorf("source_content/source_metadata references %s but its file is missing",
|
|
||||||
dupHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeIdenticalContentConcurrently starts a StoreSource for a second
|
|
||||||
// source path whose body hashes to the same content hash, returning the
|
|
||||||
// channel its error is delivered on.
|
|
||||||
func storeIdenticalContentConcurrently(
|
|
||||||
ctx context.Context, cache *Cache, content []byte,
|
|
||||||
) chan error {
|
|
||||||
storeDone := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "race.example.com",
|
|
||||||
SourcePath: "/dup.jpg",
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
result := &httpfetcher.FetchResult{
|
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: int64(len(content)),
|
|
||||||
Headers: map[string][]string{
|
|
||||||
testHeaderContentType: {testContentTypeJPEG},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := cache.StoreSource(ctx, req, bytes.NewReader(content), result)
|
|
||||||
storeDone <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
return storeDone
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,135 +1,7 @@
|
|||||||
// Package imgcache provides interfaces and types for the image caching proxy.
|
// Package imgcache provides types for the image caching proxy.
|
||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import "io"
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
|
||||||
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
|
||||||
|
|
||||||
// ImageFormat represents supported output image formats.
|
|
||||||
type ImageFormat string
|
|
||||||
|
|
||||||
// Supported image output formats.
|
|
||||||
const (
|
|
||||||
FormatOriginal ImageFormat = "orig"
|
|
||||||
FormatJPEG ImageFormat = "jpeg"
|
|
||||||
FormatPNG ImageFormat = "png"
|
|
||||||
FormatWebP ImageFormat = "webp"
|
|
||||||
FormatAVIF ImageFormat = "avif"
|
|
||||||
FormatJXL ImageFormat = "jxl"
|
|
||||||
FormatGIF ImageFormat = "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 ImageFormat = "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
|
|
||||||
}
|
|
||||||
|
|
||||||
// FitMode represents how to fit 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValidateFitMode checks if the given fit mode is valid.
|
|
||||||
// Returns ErrInvalidFitMode for unrecognized fit modes.
|
|
||||||
func ValidateFitMode(fit FitMode) 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 *ImageRequest) error {
|
|
||||||
err := ValidateDimension("width", req.Size.Width)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ValidateDimension("height", req.Size.Height)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ValidateFitMode(req.FitMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageRequest represents a request for a processed image
|
|
||||||
type ImageRequest struct {
|
|
||||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
|
||||||
SourceHost string
|
|
||||||
// SourcePath is the path on the origin with its percent-escapes decoded,
|
|
||||||
// as url.URL's Path holds it (e.g., "/photos/my cat.jpg")
|
|
||||||
SourcePath string
|
|
||||||
// SourceQuery is the optional query string for the origin URL as the URL
|
|
||||||
// writes it, percent-escapes included, as url.URL's RawQuery holds it
|
|
||||||
SourceQuery string
|
|
||||||
// Size is the requested output dimensions
|
|
||||||
Size Size
|
|
||||||
// Format is the requested output format
|
|
||||||
Format ImageFormat
|
|
||||||
// Quality is the output quality (1-100) for lossy formats
|
|
||||||
Quality int
|
|
||||||
// FitMode is how to fit the image into requested dimensions
|
|
||||||
FitMode FitMode
|
|
||||||
// Signature is the HMAC signature for non-allowlisted hosts
|
|
||||||
Signature string
|
|
||||||
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
|
||||||
// of an encrypted URL; the zero time if it has none
|
|
||||||
Expires time.Time
|
|
||||||
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
|
||||||
AllowHTTP bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceURL returns the full upstream URL to fetch: the source path
|
|
||||||
// percent-encoded again, as net/url writes a path, so each source path is one
|
|
||||||
// upstream path, and the source query as it is, with the characters
|
|
||||||
// escapeQuery encodes percent-encoded. Uses http:// scheme when AllowHTTP is
|
|
||||||
// true, otherwise https://.
|
|
||||||
func (r *ImageRequest) SourceURL() string {
|
|
||||||
scheme := "https"
|
|
||||||
if r.AllowHTTP {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceURL := url.URL{
|
|
||||||
Scheme: scheme,
|
|
||||||
Host: r.SourceHost,
|
|
||||||
Path: r.SourcePath,
|
|
||||||
RawQuery: escapeQuery(r.SourceQuery),
|
|
||||||
}
|
|
||||||
|
|
||||||
return sourceURL.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageResponse represents a processed image ready to serve
|
// ImageResponse represents a processed image ready to serve
|
||||||
type ImageResponse struct {
|
type ImageResponse struct {
|
||||||
@@ -141,9 +13,7 @@ type ImageResponse struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
// ETag is the entity tag for caching
|
// ETag is the entity tag for caching
|
||||||
ETag string
|
ETag string
|
||||||
// LastModified is when the content was last modified
|
// CacheStatus indicates HIT or MISS
|
||||||
LastModified time.Time
|
|
||||||
// CacheStatus indicates HIT, MISS, or STALE
|
|
||||||
CacheStatus CacheStatus
|
CacheStatus CacheStatus
|
||||||
// FetchedBytes is the number of bytes fetched from upstream (0 if cache hit)
|
// FetchedBytes is the number of bytes fetched from upstream (0 if cache hit)
|
||||||
FetchedBytes int64
|
FetchedBytes int64
|
||||||
@@ -156,33 +26,4 @@ type CacheStatus string
|
|||||||
const (
|
const (
|
||||||
CacheHit CacheStatus = "HIT"
|
CacheHit CacheStatus = "HIT"
|
||||||
CacheMiss CacheStatus = "MISS"
|
CacheMiss CacheStatus = "MISS"
|
||||||
CacheStale CacheStatus = "STALE"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageCache is the main interface for the image caching proxy
|
|
||||||
type ImageCache interface {
|
|
||||||
// Get retrieves a processed image, fetching and processing if necessary
|
|
||||||
Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error)
|
|
||||||
|
|
||||||
// Warm pre-fetches and caches an image without returning it
|
|
||||||
Warm(ctx context.Context, req *ImageRequest) error
|
|
||||||
|
|
||||||
// Stats returns cache statistics
|
|
||||||
Stats(ctx context.Context) (*CacheStats, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CacheStats contains cache statistics
|
|
||||||
type CacheStats struct {
|
|
||||||
// TotalItems is the number of cached source images plus processed
|
|
||||||
// variants
|
|
||||||
TotalItems int64
|
|
||||||
// TotalSizeBytes is the total size of cached source images and
|
|
||||||
// processed variants
|
|
||||||
TotalSizeBytes int64
|
|
||||||
// HitCount is the number of cache hits
|
|
||||||
HitCount int64
|
|
||||||
// MissCount is the number of cache misses
|
|
||||||
MissCount int64
|
|
||||||
// HitRate is HitCount / (HitCount + MissCount)
|
|
||||||
HitRate float64
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,18 +9,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
||||||
// keep the photo's aspect ratio.
|
// keep the photo's aspect ratio.
|
||||||
func widthOnlyRequest(fixtures *TestFixtures, width int) *ImageRequest {
|
func widthOnlyRequest(fixtures *TestFixtures, width int) *imagerequest.Request {
|
||||||
return &ImageRequest{
|
return &imagerequest.Request{
|
||||||
SourceHost: fixtures.GoodHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testPathPhoto,
|
Host: fixtures.GoodHost,
|
||||||
Size: Size{Width: width},
|
Path: testPathPhoto,
|
||||||
Format: FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: width},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +41,7 @@ func holdProcessingSlot(
|
|||||||
input, feed := io.Pipe()
|
input, feed := io.Pipe()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = proc.Process(t.Context(), input, &imageprocessor.Request{})
|
_, _ = proc.Process(t.Context(), input, &imagerequest.Transform{})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := feed.Write([]byte{0})
|
_, err := feed.Write([]byte{0})
|
||||||
@@ -61,7 +66,7 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
svc.processor = imageprocessor.New(
|
svc.processor = imageprocessor.New(
|
||||||
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
// A first request caches the photo as a source.
|
// A first request caches the photo as a source.
|
||||||
@@ -72,11 +77,11 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
sourceKey, err := svc.cache.LookupSource(t.Context(),
|
||||||
widthOnlyRequest(fixtures, 50))
|
widthOnlyRequest(fixtures, 50))
|
||||||
if err != nil || contentHash == "" {
|
if err != nil || sourceKey == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
||||||
contentHash, err)
|
sourceKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release := holdProcessingSlot(t, svc.processor)
|
release := holdProcessingSlot(t, svc.processor)
|
||||||
@@ -98,7 +103,7 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
// before waiting, it would have read it by now.
|
// before waiting, it would have read it by now.
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
err = os.WriteFile(svc.cache.srcContent.hashToPath(contentHash),
|
err = os.WriteFile(svc.cache.srcContent.keyToPath(sourceKey),
|
||||||
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to rewrite the cached source: %v", err)
|
t.Fatalf("failed to rewrite the cached source: %v", err)
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// webpRequest returns a request for a 100x100 WebP variant of path.
|
|
||||||
func webpRequest(path string) *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: path,
|
|
||||||
Size: Size{Width: 100, Height: 100},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertVariantServed checks that GetVariant serves key with the given
|
|
||||||
// content and the image/webp content type storeEvictionTestVariant stores.
|
|
||||||
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, size, contentType, err := cache.GetVariant(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
got, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading variant %s: %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Equal(got, content) {
|
|
||||||
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if size != int64(len(content)) {
|
|
||||||
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType != testContentTypeWebP {
|
|
||||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
||||||
key, contentType, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertVariantNotFound checks that GetVariant refuses key with
|
|
||||||
// ErrNotFound.
|
|
||||||
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, _, _, err := cache.GetVariant(key)
|
|
||||||
if err == nil {
|
|
||||||
_ = reader.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertLookupMisses checks that Lookup reports request as a miss.
|
|
||||||
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lookup, err := cache.Lookup(t.Context(), request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lookup.Hit {
|
|
||||||
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renameFile renames the file at from to to.
|
|
||||||
func renameFile(t *testing.T, from, to string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
err := os.Rename(from, to)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("renaming %s: %v", from, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
|
||||||
// stored or read, a hit takes its content type from memory: with the
|
|
||||||
// .meta file deleted, GetVariant must still return the stored content
|
|
||||||
// type rather than the application/octet-stream it uses without one.
|
|
||||||
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
// A second Cache on the same state directory starts with nothing in
|
|
||||||
// memory, as pixad does after a restart, so its first read uses the
|
|
||||||
// .meta file.
|
|
||||||
restarted, err := NewCache(cache.db, cache.config)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewCache() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
|
|
||||||
// which began before StoreVariant finished cannot replace the content
|
|
||||||
// type the store kept in memory. Such a read can find the variant file
|
|
||||||
// but not yet its .meta file, and so gets application/octet-stream. The
|
|
||||||
// test deletes the .meta file after the store, then runs the part of
|
|
||||||
// GetVariant that comes after its check of memory.
|
|
||||||
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadOfOlderMetaFileKeepsStoredContentType checks that a read
|
|
||||||
// which got its content type from a .meta file that StoreVariant had
|
|
||||||
// not yet rewritten cannot replace the type the store kept in memory.
|
|
||||||
// The test writes such a .meta file, with a different content type,
|
|
||||||
// after the store, then runs the part of GetVariant that comes after
|
|
||||||
// its check of memory.
|
|
||||||
func TestReadOfOlderMetaFileKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
olderMeta, err := json.Marshal(VariantMeta{
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
Size: int64(len(content)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encoding .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metaPath := cache.variants.keyToPath(testVariantKeyOne) + ".meta"
|
|
||||||
|
|
||||||
err = os.WriteFile(metaPath, olderMeta, StorageFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if contentType != testContentTypeJPEG {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) content type = %q, want %q from the .meta file",
|
|
||||||
testVariantKeyOne, contentType, testContentTypeJPEG)
|
|
||||||
}
|
|
||||||
|
|
||||||
kept, _ := cache.metaCache.Get(testVariantKeyOne)
|
|
||||||
if kept != testContentTypeWebP {
|
|
||||||
t.Errorf("content type in memory = %q, want the stored %q",
|
|
||||||
kept, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedReadDuringStoreKeepsStoredContentType checks that a read
|
|
||||||
// which found no .meta file cannot leave application/octet-stream in
|
|
||||||
// memory, even when another read has removed the content type
|
|
||||||
// StoreVariant kept there. In this order: the store; a read that finds
|
|
||||||
// the variant in memory but cannot open its file, and so removes it
|
|
||||||
// from memory; a read that opened the variant file before the store
|
|
||||||
// wrote its .meta file. Later hits must get the stored content type.
|
|
||||||
func TestFailedReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
variantPath := cache.variants.keyToPath(testVariantKeyOne)
|
|
||||||
metaPath := variantPath + ".meta"
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
renameFile(t, variantPath, variantPath+".hidden")
|
|
||||||
assertVariantNotFound(t, cache, testVariantKeyOne)
|
|
||||||
renameFile(t, variantPath+".hidden", variantPath)
|
|
||||||
|
|
||||||
renameFile(t, metaPath, metaPath+".hidden")
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.GetVariant(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
||||||
|
|
||||||
renameFile(t, metaPath+".hidden", metaPath)
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
|
||||||
// removed is a miss and cannot be read, although it had been stored
|
|
||||||
// and served before.
|
|
||||||
func TestEvictedVariantIsNotServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1500)
|
|
||||||
|
|
||||||
oldRequest := webpRequest("/old.jpg")
|
|
||||||
newRequest := webpRequest("/new.jpg")
|
|
||||||
oldKey := CacheKey(oldRequest)
|
|
||||||
newKey := CacheKey(newRequest)
|
|
||||||
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
|
||||||
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
|
||||||
storeEvictionTestVariant(t, cache, newKey, newContent)
|
|
||||||
assertVariantServed(t, cache, oldKey, oldContent)
|
|
||||||
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertLookupMisses(t, cache, oldRequest)
|
|
||||||
assertVariantNotFound(t, cache, oldKey)
|
|
||||||
assertVariantServed(t, cache, newKey, newContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
|
||||||
// file was deleted by something other than the evictor cannot be read,
|
|
||||||
// and is a miss afterwards.
|
|
||||||
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
|
|
||||||
request := webpRequest("/deleted.jpg")
|
|
||||||
key := CacheKey(request)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, key, content)
|
|
||||||
assertVariantServed(t, cache, key, content)
|
|
||||||
|
|
||||||
err := os.Remove(cache.variants.keyToPath(key))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing variant file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantNotFound(t, cache, key)
|
|
||||||
assertLookupMisses(t, cache, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
|
||||||
// variants from several goroutines at once, for the race detector.
|
|
||||||
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for goroutine := range 8 {
|
|
||||||
wg.Go(func() {
|
|
||||||
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
|
||||||
content := []byte(key)
|
|
||||||
|
|
||||||
for range 20 {
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.GetVariant(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("GetVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if contentType != testContentTypeWebP {
|
|
||||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
||||||
key, contentType, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cache.evictVariant(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("evictVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantNotFound(t, cache, key)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||||
@@ -17,15 +20,18 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := &ImageRequest{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testHostExample,
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/missing.jpg",
|
Host: testHostExample,
|
||||||
|
Path: "/missing.jpg",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initially should not be in negative cache
|
// Initially should not be in negative cache
|
||||||
@@ -39,7 +45,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store a negative entry
|
// Store a negative entry
|
||||||
err = cache.StoreNegative(ctx, req, 404, "not found")
|
err = cache.StoreNegative(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -64,19 +70,22 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := &ImageRequest{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testHostExample,
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/expired.jpg",
|
Host: testHostExample,
|
||||||
|
Path: "/expired.jpg",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store a negative entry with very short TTL
|
// Store a negative entry with very short TTL
|
||||||
err = cache.StoreNegative(ctx, req, 500, "server error")
|
err = cache.StoreNegative(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -106,20 +115,20 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() first request error = %v", err)
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
err = svc.cache.StoreNegative(t.Context(), photoVariant(fixtures, 85, FitCover),
|
err = svc.cache.StoreNegative(t.Context(),
|
||||||
httpStatusBadGateway, "upstream answered 503")
|
photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreNegative() error = %v", err)
|
t.Fatalf("StoreNegative() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() of the cached variant error = %v", err)
|
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -131,7 +140,7 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
|||||||
resp.CacheStatus, CacheHit)
|
resp.CacheStatus, CacheHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
|
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)
|
t.Logf("Get() of a variant made from the cached source: error = %v", err)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,18 +149,18 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
sourceKey, err := svc.cache.LookupSource(t.Context(),
|
||||||
photoVariant(fixtures, 85, FitCover))
|
photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if err != nil || contentHash == "" {
|
if err != nil || sourceKey == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err)
|
t.Fatalf("LookupSource() = %q, %v, want the cached source", sourceKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = svc.cache.evictSourceBlob(t.Context(), contentHash)
|
err = svc.cache.evictSourceBlob(t.Context(), sourceKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("evictSourceBlob() error = %v", err)
|
t.Fatalf("evictSourceBlob() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, FitCover))
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, imagerequest.FitCover))
|
||||||
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
|
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
|
||||||
|
|
||||||
if !errors.Is(err, ErrNegativeCached) {
|
if !errors.Is(err, ErrNegativeCached) {
|
||||||
@@ -171,10 +180,10 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
disableDiskCache bool
|
maxBytes int64
|
||||||
}{
|
}{
|
||||||
{name: "disk cache enabled", disableDiskCache: false},
|
{name: "disk cache enabled", maxBytes: 1 << 30},
|
||||||
{name: "disk cache disabled", disableDiskCache: true},
|
{name: "disk cache disabled", maxBytes: 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -184,17 +193,17 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
|
|||||||
cache, err := NewCache(evictionTestDB(t), CacheConfig{
|
cache, err := NewCache(evictionTestDB(t), CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: time.Second,
|
NegativeTTL: time.Second,
|
||||||
DisableDiskCache: tt.disableDiskCache,
|
MaxBytes: tt.maxBytes,
|
||||||
})
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expired := &ImageRequest{
|
expired := &imagerequest.Request{
|
||||||
SourceHost: testHostExample, SourcePath: "/gone.jpg",
|
Source: imagerequest.Source{Host: testHostExample, Path: "/gone.jpg"},
|
||||||
}
|
}
|
||||||
|
|
||||||
err = cache.StoreNegative(t.Context(), expired, 404, "not found")
|
err = cache.StoreNegative(t.Context(), expired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreNegative failed: %v", err)
|
t.Fatalf("StoreNegative failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,8 +212,8 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
|
|||||||
|
|
||||||
_, err = cache.db.ExecContext(t.Context(), `
|
_, err = cache.db.ExecContext(t.Context(), `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, status_code, expires_at)
|
(source_host, source_path, expires_at)
|
||||||
VALUES (?, ?, 404, datetime('now', '+1 hour'))
|
VALUES (?, ?, datetime('now', '+1 hour'))
|
||||||
`, testHostExample, notExpiredPath)
|
`, testHostExample, notExpiredPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert a row that has not expired: %v", err)
|
t.Fatalf("failed to insert a row that has not expired: %v", err)
|
||||||
@@ -215,7 +224,7 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
|
|||||||
cache.StartEviction(100 * time.Millisecond)
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 {
|
for negativeCacheRowCount(t, cache, expired.Source.Path) > 0 {
|
||||||
if time.Since(started) > 10*time.Second {
|
if time.Since(started) > 10*time.Second {
|
||||||
t.Fatal("the expired row is still in negative_cache")
|
t.Fatal("the expired row is still in negative_cache")
|
||||||
}
|
}
|
||||||
@@ -259,7 +268,9 @@ func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int {
|
|||||||
func TestCleanExpiredDeletesInBatches(t *testing.T) {
|
func TestCleanExpiredDeletesInBatches(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
|
cache, err := NewCache(evictionTestDB(t),
|
||||||
|
CacheConfig{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||||
|
prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -272,8 +283,8 @@ func TestCleanExpiredDeletesInBatches(t *testing.T) {
|
|||||||
|
|
||||||
_, err = cache.db.ExecContext(ctx, `
|
_, err = cache.db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, status_code, expires_at)
|
(source_host, source_path, expires_at)
|
||||||
VALUES (?, ?, 404, datetime('now', '+1 hour'))
|
VALUES (?, ?, datetime('now', '+1 hour'))
|
||||||
`, testHostExample, notExpiredPath)
|
`, testHostExample, notExpiredPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert a row that has not expired: %v", err)
|
t.Fatalf("failed to insert a row that has not expired: %v", err)
|
||||||
@@ -313,7 +324,9 @@ func TestCleanExpiredDeletesInBatches(t *testing.T) {
|
|||||||
func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
|
func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
|
cache, err := NewCache(evictionTestDB(t),
|
||||||
|
CacheConfig{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||||
|
prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -343,7 +356,9 @@ func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
|
|||||||
func TestCleanExpiredStopsOnceContextEnds(t *testing.T) {
|
func TestCleanExpiredStopsOnceContextEnds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
|
cache, err := NewCache(evictionTestDB(t),
|
||||||
|
CacheConfig{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||||
|
prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -371,8 +386,8 @@ func insertExpiredNegativeCacheRows(t *testing.T, cache *Cache, count int) {
|
|||||||
for i := range count {
|
for i := range count {
|
||||||
_, err := cache.db.ExecContext(t.Context(), `
|
_, err := cache.db.ExecContext(t.Context(), `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, status_code, expires_at)
|
(source_host, source_path, expires_at)
|
||||||
VALUES (?, ?, 404, datetime('now', '-1 hour'))
|
VALUES (?, ?, datetime('now', '-1 hour'))
|
||||||
`, testHostExample, "/expired/"+strconv.Itoa(i)+".jpg")
|
`, testHostExample, "/expired/"+strconv.Itoa(i)+".jpg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert an expired row: %v", err)
|
t.Fatalf("failed to insert an expired row: %v", err)
|
||||||
|
|||||||
@@ -2,31 +2,33 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getImage calls Get for req and returns the image it answered with and
|
// getImage calls Get for req and returns the image it answered with and
|
||||||
// its cache status.
|
// its cache status.
|
||||||
func getImage(t *testing.T, svc *Service, req *ImageRequest) ([]byte, CacheStatus) {
|
func getImage(
|
||||||
|
t *testing.T, svc *Service, req *imagerequest.Request,
|
||||||
|
) ([]byte, CacheStatus) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
resp, err := svc.Get(t.Context(), req)
|
resp, err := svc.Get(t.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() of quality %d error = %v", req.Quality, err)
|
t.Fatalf("Get() of quality %d error = %v", req.Transform.Quality, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Content.Close() }()
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Content)
|
body, err := io.ReadAll(resp.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reading the image of quality %d: %v", req.Quality, err)
|
t.Fatalf("reading the image of quality %d: %v", req.Transform.Quality, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return body, resp.CacheStatus
|
return body, resp.CacheStatus
|
||||||
@@ -58,15 +60,17 @@ func truncateFile(t *testing.T, path string, size int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cachedSourcePath returns the path of the cached source file req uses.
|
// cachedSourcePath returns the path of the cached source file req uses.
|
||||||
func cachedSourcePath(t *testing.T, svc *Service, req *ImageRequest) string {
|
func cachedSourcePath(
|
||||||
|
t *testing.T, svc *Service, req *imagerequest.Request,
|
||||||
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
|
sourceKey, err := svc.cache.LookupSource(t.Context(), req)
|
||||||
if err != nil || contentHash == "" {
|
if err != nil || sourceKey == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", sourceKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc.cache.srcContent.hashToPath(contentHash)
|
return svc.cache.srcContent.keyToPath(sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
||||||
@@ -113,18 +117,18 @@ func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
wholeSize := fileSize(t, path)
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
truncateFile(t, path, 0)
|
truncateFile(t, path, 0)
|
||||||
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
||||||
|
|
||||||
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
fetches := readCounters(svc.cache).upstreamFetchCount
|
||||||
t.Logf("upstream fetches = %d", fetches)
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
if fetches != 2 {
|
if fetches != 2 {
|
||||||
@@ -149,18 +153,18 @@ func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
wholeSize := fileSize(t, path)
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
truncateFile(t, path, wholeSize/2)
|
truncateFile(t, path, wholeSize/2)
|
||||||
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
||||||
|
|
||||||
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
fetches := readCounters(svc.cache).upstreamFetchCount
|
||||||
t.Logf("upstream fetches = %d", fetches)
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
if fetches != 2 {
|
if fetches != 2 {
|
||||||
@@ -182,11 +186,11 @@ func assertConvertedAgain(t *testing.T, size int64) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
req := photoVariant(fixtures, 85, FitCover)
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
want, _ := getImage(t, svc, req)
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
path := svc.cache.variants.keyToPath(CacheKey(req))
|
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
||||||
|
|
||||||
truncateFile(t, path, size)
|
truncateFile(t, path, size)
|
||||||
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
@@ -235,8 +239,7 @@ func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil),
|
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil))
|
||||||
testContentTypeWebP)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store an empty variant file: %v", err)
|
t.Fatalf("failed to store an empty variant file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -257,63 +260,10 @@ func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVariantStorage_StoreReplacesMetaFile stores a variant over one
|
// TestContentStorage_StoreReplacesFileOfAnotherSize checks that a
|
||||||
// already stored and checks that Store puts a new .meta file in place of
|
// store over a file of another size at the source's path, such as a
|
||||||
// the old one, as it does the image, rather than writing into the old
|
|
||||||
// file, which a crash could leave empty. The old .meta file is given a
|
|
||||||
// second name before the store, so that it can be told apart from the new
|
|
||||||
// one after it.
|
|
||||||
func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storage, key := storeTestVariant(t)
|
|
||||||
metaPath := storage.keyToPath(key) + variantMetaSuffix
|
|
||||||
oldMetaPath := metaPath + ".old"
|
|
||||||
|
|
||||||
err := os.Link(metaPath, oldMetaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("giving the .meta file a second name: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storage.Store(key, bytes.NewReader([]byte("new variant data")),
|
|
||||||
testContentTypeJPEG)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
oldMeta, err := os.Stat(oldMetaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat of the old .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newMeta, err := os.Stat(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat of the new .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.SameFile(oldMeta, newMeta) {
|
|
||||||
t.Error("Store() wrote into the old .meta file instead of putting " +
|
|
||||||
"a new one in its place")
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := storage.LoadWithMeta(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadWithMeta() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("content type after the store: %s", contentType)
|
|
||||||
|
|
||||||
if contentType != testContentTypeJPEG {
|
|
||||||
t.Errorf("content type = %q, want %q", contentType, testContentTypeJPEG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a
|
|
||||||
// store over a file of another size at the content's path, such as a
|
|
||||||
// partial file a crash left, replaces it with the content.
|
// partial file a crash left, replaces it with the content.
|
||||||
func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
storage, err := NewContentStorage(t.TempDir())
|
storage, err := NewContentStorage(t.TempDir())
|
||||||
@@ -322,13 +272,12 @@ func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("the whole source image")
|
content := []byte("the whole source image")
|
||||||
sum := sha256.Sum256(content)
|
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
||||||
hash := ContentHash(hex.EncodeToString(sum[:]))
|
path := storage.keyToPath(key)
|
||||||
path := storage.hashToPath(hash)
|
|
||||||
|
|
||||||
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating the content's directory: %v", err)
|
t.Fatalf("creating the source's directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
||||||
@@ -336,14 +285,14 @@ func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
|||||||
t.Fatalf("writing a partial file: %v", err)
|
t.Fatalf("writing a partial file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.StoreHashed(hash, content)
|
_, err = storage.Store(key, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreHashed() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := storage.Load(hash)
|
stored, _, err := storage.LoadWithSize(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("LoadWithSize() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = stored.Close() }()
|
defer func() { _ = stored.Close() }()
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingWriteInterval is how often the goroutine
|
||||||
|
// StartPendingAccessTimeWrites starts writes the pending access times.
|
||||||
|
// SQLite's CURRENT_TIMESTAMP, which an access time is written as, counts
|
||||||
|
// whole seconds.
|
||||||
|
const pendingWriteInterval = time.Second
|
||||||
|
|
||||||
|
// StartPendingAccessTimeWrites starts the goroutine that writes, every
|
||||||
|
// pendingWriteInterval, the pending access times in one transaction.
|
||||||
|
// Requests only hand these to it, so none waits for the database to record
|
||||||
|
// them, and only this goroutine's write ever waits for it. It is a no-op
|
||||||
|
// when already started. The goroutine outlives the caller, so it runs with
|
||||||
|
// its own context, which StopPendingAccessTimeWrites cancels to tell it to
|
||||||
|
// finish.
|
||||||
|
func (c *Cache) StartPendingAccessTimeWrites() {
|
||||||
|
if c.pendingAccessTimesCancel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c.pendingAccessTimesCancel = cancel
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
c.pendingAccessTimeWriteLoop(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPendingAccessTimeWrites tells the goroutine
|
||||||
|
// StartPendingAccessTimeWrites started, if it did, to finish, and waits for
|
||||||
|
// its write under way to end and for it to return. It then writes the
|
||||||
|
// pending access times left, once, so that they are written before the
|
||||||
|
// database closes. It waits for all of this at most until ctx ends. When
|
||||||
|
// access times are not written, it logs that and returns an error.
|
||||||
|
func (c *Cache) StopPendingAccessTimeWrites(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if c.pendingAccessTimesCancel != nil {
|
||||||
|
c.pendingAccessTimesCancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.pendingAccessTimesDone:
|
||||||
|
case <-ctx.Done():
|
||||||
|
// The write given up on here may not reach the database:
|
||||||
|
// database/sql closes without waiting for a query, and a write
|
||||||
|
// still waiting for the connection fails when the database
|
||||||
|
// closes. Nothing is written here.
|
||||||
|
err = fmt.Errorf("access times still being written: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
err = c.writePendingAccessTimes(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.log.Error("access times not written at shutdown", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingAccessTimeWriteLoop is the body of the goroutine
|
||||||
|
// StartPendingAccessTimeWrites starts. Once ctx is cancelled it starts no
|
||||||
|
// write and returns, but does not cut off a write under way then. Access
|
||||||
|
// times whose write fails are dropped.
|
||||||
|
func (c *Cache) pendingAccessTimeWriteLoop(ctx context.Context) {
|
||||||
|
defer close(c.pendingAccessTimesDone)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(pendingWriteInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
// select picks at random when ctx is cancelled and a tick waits
|
||||||
|
// too.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.writePendingAccessTimes(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to write access times", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePendingAccessTimes takes out the variants waiting in
|
||||||
|
// pendingVariantAccessTimes and the sources waiting in
|
||||||
|
// pendingSourceAccessTimes, and sets their last_accessed_at to now in one
|
||||||
|
// transaction, once for each however often it was used. Nothing else takes
|
||||||
|
// from those channels while it runs: only the goroutine
|
||||||
|
// StartPendingAccessTimeWrites starts calls it, or
|
||||||
|
// StopPendingAccessTimeWrites once that goroutine has returned.
|
||||||
|
func (c *Cache) writePendingAccessTimes(ctx context.Context) error {
|
||||||
|
variants := make(map[imagerequest.VariantKey]struct{})
|
||||||
|
|
||||||
|
for range len(c.pendingVariantAccessTimes) {
|
||||||
|
variants[<-c.pendingVariantAccessTimes] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := make(map[imagerequest.SourceKey]struct{})
|
||||||
|
|
||||||
|
for range len(c.pendingSourceAccessTimes) {
|
||||||
|
sources[<-c.pendingSourceAccessTimes] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTimes := len(variants) + len(sources)
|
||||||
|
if accessTimes == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does nothing once the transaction is committed
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
for cacheKey := range variants {
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE cache_key = ?
|
||||||
|
`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sourceKey := range sources {
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE source_key = ?
|
||||||
|
`, string(sourceKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingAccessTimesWait is how long a test waits for the database's
|
||||||
|
// connection, for an access time to reach the database, for a write to start
|
||||||
|
// waiting for the connection, for a request to return past its deadline, or
|
||||||
|
// for StopPendingAccessTimeWrites to return.
|
||||||
|
const pendingAccessTimesWait = 5 * time.Second
|
||||||
|
|
||||||
|
// otherWritesWait is how long a test gives other writes, once one write waits
|
||||||
|
// for the database's connection, to start waiting for it too.
|
||||||
|
const otherWritesWait = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// holdDatabase takes the one connection of cache's database, so that every
|
||||||
|
// other query waits for it, and returns the func that frees it.
|
||||||
|
func holdDatabase(t *testing.T, cache *Cache) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), pendingAccessTimesWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := cache.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() { _ = conn.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForConnectionWaits waits until db.Stats().WaitCount, the number of
|
||||||
|
// times a caller has waited for a connection, reaches waits.
|
||||||
|
func waitForConnectionWaits(t *testing.T, db *sql.DB, waits int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingAccessTimesWait)
|
||||||
|
|
||||||
|
for db.Stats().WaitCount < waits {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("callers waited for the database connection %d times, want %d",
|
||||||
|
db.Stats().WaitCount, waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_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 TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(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.cache)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingAccessTimeWrites_LetsTheWriteUnderWayFinish holds the
|
||||||
|
// database's one connection while the goroutine that writes the pending
|
||||||
|
// access times waits for it, hands over another access time, then stops that
|
||||||
|
// goroutine. The stop must leave the write under way to finish rather than
|
||||||
|
// cut it off, and once the connection is free it must write the access time
|
||||||
|
// that write left.
|
||||||
|
func TestStopPendingAccessTimeWrites_LetsTheWriteUnderWayFinish(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.StartPendingAccessTimeWrites()
|
||||||
|
defer func() { _ = cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
|
||||||
|
// The goroutine's write waits for the connection.
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
|
|
||||||
|
// Handed over after that write took the pending access times
|
||||||
|
cache.touchVariant(testVariantKeyTwo)
|
||||||
|
|
||||||
|
// The goroutine is told to finish here as well as by the stop, so that it
|
||||||
|
// is told before its write under way ends, however late the stop runs.
|
||||||
|
// The access time above then reaches the database only through the
|
||||||
|
// stop's own write.
|
||||||
|
cache.pendingAccessTimesCancel()
|
||||||
|
|
||||||
|
stopped := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
stopped <- cache.StopPendingAccessTimeWrites(t.Context())
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Had the stop cut off the write under way, its own write would wait for
|
||||||
|
// the connection too.
|
||||||
|
time.Sleep(otherWritesWait)
|
||||||
|
|
||||||
|
waits := cache.db.Stats().WaitCount - waitsBefore
|
||||||
|
t.Logf("%d writes waited for the database", waits)
|
||||||
|
|
||||||
|
if waits > 1 {
|
||||||
|
t.Errorf("%d writes waited for the database, want 1: "+
|
||||||
|
"the stop cut off the write under way", waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopped:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingAccessTimesWait):
|
||||||
|
t.Fatal("StopPendingAccessTimeWrites() did not return once the database was free")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
|
||||||
|
if countRows(t, cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) != 1 {
|
||||||
|
t.Errorf("the access time of variant %s was not written", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingAccessTimeWrites_ReturnsWhenItsContextEnds holds the
|
||||||
|
// database's one connection while the goroutine that writes the pending
|
||||||
|
// access times waits for it, then stops that goroutine with a context that
|
||||||
|
// ends first. The stop must return the context's error, and the write under
|
||||||
|
// way must still reach the database once the connection is free.
|
||||||
|
func TestStopPendingAccessTimeWrites_ReturnsWhenItsContextEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, []byte("variant bytes"))
|
||||||
|
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setVariantLastAccessed(t, cache, testVariantKeyOne, anHourAgo)
|
||||||
|
|
||||||
|
cache.StartPendingAccessTimeWrites()
|
||||||
|
defer func() { _ = cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
|
||||||
|
// The goroutine's write waits for the connection.
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
|
|
||||||
|
stopCtx, cancelStop := context.WithCancel(t.Context())
|
||||||
|
cancelStop()
|
||||||
|
|
||||||
|
stopped := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
stopped <- cache.StopPendingAccessTimeWrites(stopCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopped:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("StopPendingAccessTimeWrites() error = %v, want %v",
|
||||||
|
err, context.Canceled)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingAccessTimesWait):
|
||||||
|
t.Fatal("StopPendingAccessTimeWrites() did not return once its context ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingAccessTimesWait)
|
||||||
|
|
||||||
|
for countRows(t, cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(testVariantKeyOne), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the access time being written was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLookupSource_HandsAccessTimeToTheBackgroundWriter finds a cached source
|
||||||
|
// whose access time is an hour old. LookupSource must not write the access
|
||||||
|
// time itself but hand it over to be written, and StopPendingAccessTimeWrites
|
||||||
|
// must then write it.
|
||||||
|
func TestLookupSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg",
|
||||||
|
[]byte("source bytes"))
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setSourceLastAccessed(t, cache, key, anHourAgo)
|
||||||
|
|
||||||
|
touchedQuery := `
|
||||||
|
SELECT COUNT(*) FROM source_content
|
||||||
|
WHERE source_key = ? AND last_accessed_at > ?
|
||||||
|
`
|
||||||
|
anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat)
|
||||||
|
|
||||||
|
found, err := cache.LookupSource(t.Context(), &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "example.com", Path: "/photo.jpg"},
|
||||||
|
})
|
||||||
|
if err != nil || found != key {
|
||||||
|
t.Fatalf("LookupSource() = %q, %v; want %q", found, err, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countRows(t, cache, touchedQuery, string(key), anHourAgoInDatabase) != 0 {
|
||||||
|
t.Error("LookupSource wrote the source's access time itself")
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingSourceAccessTimes); waiting != 1 {
|
||||||
|
t.Errorf("%d access times of sources wait to be written, want 1", waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.StopPendingAccessTimeWrites(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countRows(t, cache, touchedQuery, string(key), anHourAgoInDatabase) != 1 {
|
||||||
|
t.Error("the source's access time was not written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingAccessTimeWrites_LogsWhatWasNotWritten makes the write of
|
||||||
|
// the access times fail at shutdown, by dropping the table they are written
|
||||||
|
// to. The stop must return an error and log that access times were not
|
||||||
|
// written.
|
||||||
|
func TestStopPendingAccessTimeWrites_LogsWhatWasNotWritten(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
cache.log = slog.New(slog.NewJSONHandler(&logs, nil))
|
||||||
|
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
|
||||||
|
_, err := cache.db.ExecContext(t.Context(), `DROP TABLE variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dropping variant_content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.StopPendingAccessTimeWrites(t.Context())
|
||||||
|
t.Logf("StopPendingAccessTimeWrites() error = %v; logged:\n%s", err, logs.String())
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("StopPendingAccessTimeWrites() error = nil, want the access times' error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(logs.String(), "access times not written at shutdown") {
|
||||||
|
t.Error("the access times not written were not logged")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user