Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc080147af |
+13
-21
@@ -17,14 +17,13 @@ COPY . .
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. script/test builds it alone.
|
||||
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
||||
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
|
||||
# golang:1.25.4-alpine, 2026-02-25
|
||||
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# script/bootstrap --cgo installs the build dependencies (a C compiler,
|
||||
# the libvips and libheif headers, and libvips' JPEG XL support, which
|
||||
# the tests need) and downloads the Go modules.
|
||||
# script/bootstrap --cgo installs the build dependencies (a C compiler
|
||||
# and the libvips and libheif headers) and downloads the Go modules.
|
||||
COPY script/ ./script/
|
||||
COPY go.mod go.sum ./
|
||||
RUN script/bootstrap --cgo
|
||||
@@ -32,19 +31,16 @@ RUN script/bootstrap --cgo
|
||||
COPY . .
|
||||
|
||||
# Without -v first; on a failure, again with -v for the details, and
|
||||
# the step fails even if the second run passes. -parallel 4: by default
|
||||
# a package runs as many of its tests at once as the host has CPUs, and
|
||||
# on a busy host they then wait so long to be scheduled that a test
|
||||
# that times a request can see it return a second late.
|
||||
RUN go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \
|
||||
# the step fails even if the second run passes.
|
||||
RUN go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Build stage. Nothing is wanted from the two phases above: these copies
|
||||
# make BuildKit build them first, so this stage runs only when lint and
|
||||
# test passed.
|
||||
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
||||
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||
# golang:1.25.4-alpine, 2026-02-25
|
||||
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
@@ -80,17 +76,13 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
||||
-o /pixad ./cmd/pixad
|
||||
|
||||
# Runtime stage, and the last one: a plain `docker build .` builds this
|
||||
# stage and what it depends on, and nothing else. It must use the Alpine
|
||||
# release the golang image above is based on, so that pixad runs against
|
||||
# the libvips and musl it was built with.
|
||||
# alpine:3.22, 2026-10-08
|
||||
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
||||
# stage and what it depends on, and nothing else.
|
||||
# alpine:3.21, 2026-02-25
|
||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||
|
||||
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
||||
# support, without which pixad does not start.
|
||||
# Install runtime dependencies only
|
||||
RUN apk add --no-cache \
|
||||
vips \
|
||||
vips-jxl \
|
||||
libheif \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
|
||||
@@ -46,9 +46,9 @@ everything in this list without further settings. The reverse proxy must:
|
||||
builds encrypted URLs from `Host`, and checks `Referer` against
|
||||
`referer_blocklist`;
|
||||
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
||||
the address pixa sees the proxy's requests come from, so the login limit and
|
||||
`image_requests_per_minute` count each client by its own address (see
|
||||
`trusted_proxies` under Configuration);
|
||||
the address pixa sees the proxy's requests come from, so the login limit
|
||||
counts each client by its own address (see `trusted_proxies` under
|
||||
Configuration);
|
||||
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
||||
longest pixa takes to fetch, convert and send an image.
|
||||
|
||||
@@ -58,9 +58,8 @@ password there.
|
||||
|
||||
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
||||
allowlist, refuses upstream hosts with private or local addresses, limits login
|
||||
attempts, each client's image requests, upstream response size and image
|
||||
dimensions, and sends the security headers, `Strict-Transport-Security`
|
||||
included, with every response.
|
||||
attempts, upstream response size and image dimensions, and sends the security
|
||||
headers, `Strict-Transport-Security` included, with every response.
|
||||
|
||||
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
||||
database and the disk cache:
|
||||
@@ -84,18 +83,13 @@ which answers 200 whenever pixa is running, in maintenance mode too (see
|
||||
`maintenance_mode`).
|
||||
|
||||
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
||||
progress and the images being processed 5 seconds to finish, then writes to the
|
||||
database the counts of cache hits, misses, fetches and conversions that requests
|
||||
could not write by their deadline, and exits: with 0, or with 1 when images were
|
||||
still being processed after those 5 seconds, some of those counts could not be
|
||||
written, or another part of pixa failed to stop. A request not finished by then
|
||||
is cut off. `docker stop` waits 10 seconds before it kills the container.
|
||||
progress and the images being processed 5 seconds to finish, and exits: with 0,
|
||||
or with 1 when images were still being processed after those 5 seconds or
|
||||
another part of pixa failed to stop. A request not finished by then is cut off.
|
||||
`docker stop` waits 10 seconds before it kills the container.
|
||||
|
||||
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
|
||||
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
||||
support, which on Alpine is the `vips-jxl` package and which the nix and brew
|
||||
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
|
||||
on. Building pixa also needs the development files of libvips and libheif,
|
||||
Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
|
||||
it uses libvips through CGO; building it also needs their development files,
|
||||
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these,
|
||||
as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which
|
||||
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
|
||||
@@ -178,24 +172,20 @@ path under `/v1/` answers 200, in maintenance mode too.
|
||||
with the page naming a field that is not valid; 500 when the URL cannot be
|
||||
made.
|
||||
- `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`.
|
||||
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
|
||||
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
|
||||
and converted (below). Needs: a signature, unless the host is allowlisted (see
|
||||
Source Hosts). Answers: 200; 304 when `If-None-Match` matches the image's
|
||||
`ETag`; 400 for a URL or parameter that is not valid, or for the format `auto`
|
||||
an `Accept` header that is not valid; 406 for the format `auto` when `Accept`
|
||||
allows none of the formats it chooses from; 401 for a missing or wrong
|
||||
signature, a missing `exp` or an `exp` in the past; 403 when the request's
|
||||
`Referer` names a host in `referer_blocklist`, checked before the signature,
|
||||
the cache and the upstream fetch; 403 when the upstream host, or a host it
|
||||
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
||||
address in a blocked network (see `blocked_networks`); 429 over
|
||||
`image_requests_per_minute` (below); 415 when the upstream answers with a
|
||||
`Content-Type` that is not one of the source formats (below), or with an image
|
||||
whose first bytes libvips reads as another format; 502 when the upstream
|
||||
answered with an error status, and for 5 minutes after that for the same
|
||||
source URL; 503 when pixa is busy or in maintenance mode; 500 for any other
|
||||
failure.
|
||||
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>` — an image, fetched,
|
||||
resized 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 `ETag`; 400 for a URL or parameter that is not valid, or for the
|
||||
format `auto` an `Accept` header that is not valid; 406 for the format `auto`
|
||||
when `Accept` allows none of the formats it chooses from; 401 for a missing or
|
||||
wrong signature, a missing `exp` or an `exp` in the past; 403 when the
|
||||
request's `Referer` names a host in `referer_blocklist`, checked before the
|
||||
signature, the cache and the upstream fetch; 403 when the upstream host, or a
|
||||
host it redirects to, is `localhost`, ends in `.localhost` or `.local`, or has
|
||||
an address in a blocked network (see `blocked_networks`); 502 when the
|
||||
upstream answered with an error status, and for 5 minutes after that for the
|
||||
same source URL; 503 when pixa is busy or in maintenance mode; 500 for any
|
||||
other failure.
|
||||
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
||||
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
||||
@@ -203,7 +193,7 @@ path under `/v1/` answers 200, in maintenance mode too.
|
||||
expired; 504 when the upstream has not sent its response headers within
|
||||
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
||||
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
||||
406, 403, 415, 429, 502, 503 and 500, as for `/v1/image/`.
|
||||
406, 403, 502, 503 and 500, as for `/v1/image/`.
|
||||
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
||||
nothing. Answers: 200.
|
||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||
@@ -238,11 +228,10 @@ proxy in front of pixa must pass that header on unchanged. A form body over 1
|
||||
MiB is refused with 413. The image routes answer the errors listed for them with
|
||||
JSON holding `error`, `status` and `timestamp`.
|
||||
|
||||
An image URL has one of these forms, the second with no format:
|
||||
An image URL has this form:
|
||||
|
||||
```
|
||||
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
|
||||
/v1/image/<host>/<path>/<size>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
|
||||
```
|
||||
|
||||
Images are only fetched from origins using TLS with valid certificates, unless
|
||||
@@ -253,9 +242,7 @@ A request whose query string cannot be decoded, or gives any parameter more than
|
||||
once, is refused with 400.
|
||||
|
||||
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
|
||||
`avif`, `jxl` (JPEG XL), `gif`, or `auto` (below). A URL with no format (the
|
||||
second form, with no dot after the size) is served as JPEG XL, the default, as
|
||||
with `jxl`
|
||||
`avif`, `gif`, or `auto` (below)
|
||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||
- `sig` and `exp`: the signature and its expiry, needed unless the host is
|
||||
allowlisted (see Signature Specification)
|
||||
@@ -263,37 +250,23 @@ once, is refused with 400.
|
||||
both optional (values under Signature Specification). Both are part of what is
|
||||
cached, so each value of either is a separate cached image.
|
||||
|
||||
A size with one side `0` has that side scaled to keep the source's proportions,
|
||||
and is refused with 400 when that side would come out above 8192, the most
|
||||
either side may be; so is a size with `fit=outside`, which scales the image to
|
||||
cover the size and so past it on one side, when that side would come out
|
||||
above 8192.
|
||||
|
||||
The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
|
||||
`Content-Type` must name its format, and the image's first bytes must match it.
|
||||
An upstream answering with any other `Content-Type`, SVG included, is refused
|
||||
with 415, and so is an image whose first bytes libvips reads as another format,
|
||||
such as an SVG whose first bytes pass the check for AVIF.
|
||||
|
||||
With the format `auto`, pixa chooses the format for each request from its
|
||||
`Accept` header, in this order:
|
||||
|
||||
1. JPEG XL, when the header names `image/jxl`;
|
||||
2. AVIF, when it names `image/avif`;
|
||||
3. WebP, when it names `image/webp`;
|
||||
4. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names
|
||||
1. AVIF, when the header names `image/avif`;
|
||||
2. WebP, when it names `image/webp`;
|
||||
3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names
|
||||
allows it, or when there is no `Accept` header or it is empty.
|
||||
|
||||
An entry with `q=0` refuses its format; other `q` values do not change the
|
||||
order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them
|
||||
also send `image/*` and `*/*`. pixa never sends a format the client refused:
|
||||
when the header allows none of the four, the answer is 406, and a header that
|
||||
does not parse, or has a `q` that is not a number from 0 to 1, is refused
|
||||
with 400. The signature, or the token of an encrypted URL, covers `auto` itself,
|
||||
so one URL serves every client. Each format chosen is cached as a separate
|
||||
image, and every answer that depends on `Accept` (the image, a 304, and the 400
|
||||
and 406 above) carries `Vary: Accept`, so a shared cache keeps the formats apart
|
||||
too.
|
||||
order. AVIF and WebP must be named, as clients that cannot show them also send
|
||||
`image/*` and `*/*`. pixa never sends a format the client refused: when the
|
||||
header allows none of the three, the answer is 406, and a header that does not
|
||||
parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The
|
||||
signature, or the token of an encrypted URL, covers `auto` itself, so one URL
|
||||
serves every client. Each format chosen is cached as a separate image, and every
|
||||
answer that depends on `Accept` (the image, a 304, and the 400 and 406 above)
|
||||
carries `Vary: Accept`, so a shared cache keeps the formats apart too.
|
||||
|
||||
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
||||
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age`
|
||||
@@ -327,15 +300,6 @@ its own `X-Forwarded-For`, whether it connects directly or through the proxy,
|
||||
because its own address is trusted too. Setting `trusted_proxies` to only the
|
||||
address pixa sees for requests that come through the proxy closes this.
|
||||
|
||||
One client may send `image_requests_per_minute` requests per minute
|
||||
(default 600) to the image routes, `/v1/image/` and `/v1/e/` together, `GET` and
|
||||
`HEAD`, cache hits included; a request over the limit is refused with 429 and a
|
||||
`Retry-After` header. Clients are counted as for the login limit. A browser's
|
||||
CORS preflight is not counted. Behind a CDN or proxy whose addresses are not in
|
||||
`trusted_proxies`, all requests arriving from one proxy or CDN address count as
|
||||
one client, so such a deployment needs a higher limit, or `0`, which turns the
|
||||
limit off.
|
||||
|
||||
### Encrypted URLs
|
||||
|
||||
An encrypted URL is an image URL made on pixa's own web page by someone who
|
||||
@@ -348,16 +312,13 @@ nor change what it asks for.
|
||||
lasts 30 days, or until `/logout`.
|
||||
2. On the generator page, give the source image's URL, the width and height, the
|
||||
format, quality and fit, and how long the URL lasts, then submit the form
|
||||
(`POST /generate`). The format is JPEG XL unless another is chosen; a form
|
||||
sent with an empty format, or none, also makes a JPEG XL URL. Width and
|
||||
height both empty or `0` keep the original size; if only one of them is empty
|
||||
or `0`, that side is scaled to keep the image's proportions. A URL whose
|
||||
scaled side would come out above 8192 answers 400.
|
||||
(`POST /generate`). Width and height both empty or `0` keep the original
|
||||
size; if only one of them is empty or `0`, that side is scaled to keep the
|
||||
image's proportions.
|
||||
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
||||
it expires. `<host>` is the host the page was opened on, and the URL starts
|
||||
with `http` instead while `debug` is on. The name after the token is ignored
|
||||
and only gives the URL a file extension, `jpg` for `orig` and `auto`, and
|
||||
`jxl` for a form with no format.
|
||||
and only gives the URL a file extension, `jpg` for `orig` and `auto`.
|
||||
|
||||
The token holds the source's host, path and query and the size, format, quality,
|
||||
fit and expiry, encrypted with a key derived from `signing_key`. The source
|
||||
@@ -386,10 +347,6 @@ turned off.
|
||||
- An image with an ICC profile is converted to sRGB first, since clients show an
|
||||
image with no profile as sRGB. Colours outside sRGB, such as the most
|
||||
saturated ones in a Display P3 photo, are clipped.
|
||||
- The one exception is JPEG XL with libvips 8.16 and later: libvips then writes
|
||||
an EXIF block of its own into the image, as govips cannot ask libvips to leave
|
||||
it out. It holds the image's size and otherwise fixed values, such as an
|
||||
orientation of 1 and a resolution of 72 dpi, and nothing from the source.
|
||||
|
||||
### Source Hosts
|
||||
|
||||
@@ -417,9 +374,8 @@ Where:
|
||||
- `width` — requested width in pixels, `0` for original
|
||||
- `height` — requested height in pixels, `0` for original
|
||||
- `format` — output format, one of those listed under Routes, with `original`
|
||||
signed as `orig`, `jpg` as `jpeg`, and no format as `jxl`, so a URL with no
|
||||
format has the signature of the same URL ending in `.jxl`; `auto` is signed as
|
||||
`auto`, not as the format chosen for the request
|
||||
signed as `orig` and `jpg` as `jpeg`; `auto` is signed as `auto`, not as the
|
||||
format chosen for the request
|
||||
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when the
|
||||
signature expires; a request whose `exp` is not a whole number, an empty
|
||||
`exp=` included, is refused with 400
|
||||
@@ -506,7 +462,6 @@ tell. With no file, pixa uses the environment and the defaults.
|
||||
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
||||
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
||||
| `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
|
||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||
@@ -556,13 +511,13 @@ Key settings in more detail:
|
||||
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
||||
to the address pixa sees for requests that come through your proxy, such as
|
||||
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
||||
(see the login limit and `image_requests_per_minute` under Routes). For a
|
||||
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
|
||||
is the gateway of the container's Docker network (`172.17.0.1` on the default
|
||||
bridge), not the proxy's own address; a proxy that connects through another of
|
||||
the host's addresses is seen with that address. To be sure which address it
|
||||
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
||||
through the proxy, and read `remoteIP` in pixa's request log line for it
|
||||
(see the login limit under Routes). For a proxy on the Docker host that
|
||||
connects to pixa over `127.0.0.1`, that address is the gateway of the
|
||||
container's Docker network (`172.17.0.1` on the default bridge), not the
|
||||
proxy's own address; a proxy that connects through another of the host's
|
||||
addresses is seen with that address. To be sure which address it is, set this
|
||||
to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the
|
||||
proxy, and read `remoteIP` in pixa's request log line for it
|
||||
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||
as a duration such as `30s` (the default) or `2m`
|
||||
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||
@@ -575,10 +530,12 @@ Key settings in more detail:
|
||||
- `db_url` — the SQLite database to open; omitted, it is
|
||||
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||
database in WAL mode. pixa opens one connection to it, so its own reads and
|
||||
writes run one at a time. pixa adds `_pragma=busy_timeout(5000)` to any
|
||||
`db_url`, so a write that finds another program writing to the file waits up
|
||||
to five seconds for it instead of failing. WAL mode comes only from the URL:
|
||||
keep `_pragma=journal_mode(WAL)` in one you set
|
||||
writes run one at a time. Requests wait while eviction runs one of its
|
||||
queries, some of which read a whole table. pixa adds
|
||||
`_pragma=busy_timeout(5000)` to any `db_url`, so a write that finds another
|
||||
program writing to the file waits up to five seconds for it instead of
|
||||
failing. WAL mode comes only from the URL: keep `_pragma=journal_mode(WAL)` in
|
||||
one you set
|
||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk
|
||||
cache entirely; omitted defaults to 75% of the sum of the free space on the
|
||||
filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||
@@ -595,12 +552,6 @@ Key settings in more detail:
|
||||
container's CPU limit. A request that finds all of them in use waits up to 10
|
||||
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||
ended first, it is answered 503 the same way
|
||||
- `image_requests_per_minute` — how many requests one client may send to the
|
||||
image routes per minute (see the limit on them under Routes); default `600`.
|
||||
`0` turns the limit off, and a negative or non-integer value aborts startup.
|
||||
Behind a CDN or proxy whose addresses are not in `trusted_proxies`, all
|
||||
requests arriving from one proxy or CDN address count as one client, so raise
|
||||
the limit there, or set `0`
|
||||
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
||||
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
||||
@@ -634,8 +585,8 @@ provide:
|
||||
|
||||
- `script/bootstrap` — install git, make, Go, Node, Yarn and prettier and
|
||||
download the Go modules (idempotent); with `--cgo`, the C compiler and the
|
||||
libvips (with its JPEG XL support) and libheif libraries that compiling and
|
||||
testing pixa need instead of Node, Yarn and prettier
|
||||
libvips and libheif libraries that compiling pixa needs instead of Node, Yarn
|
||||
and prettier
|
||||
- `script/setup` — make a fresh clone ready for development (bootstrap, then
|
||||
install-precommit)
|
||||
- `script/projectname` — output the project name ("pixa")
|
||||
|
||||
@@ -26,129 +26,10 @@ resolved. The disk cache is now size-bounded with LRU eviction
|
||||
|
||||
# Next Step
|
||||
|
||||
P2: security: per-origin rate limiting
|
||||
P2: security: per-IP rate limiting on the image routes
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-13 `fit=outside` no longer makes an output above 8192 on a side
|
||||
(closes #257): it scales the image to cover the requested size, and the side
|
||||
scaled past that size was held to no maximum, so `1x8192?fit=outside` of a
|
||||
3000x600 source was resized to 40960x8192 and encoded. The image processor now
|
||||
refuses such a size with `ErrOutputTooLarge` before resizing, and both image
|
||||
routes answer it 400, as for a size with one side `0`. The other fit modes
|
||||
make the requested size or one within it, so none can go above 8192.
|
||||
- 2026-10-13 a size with one side `0` no longer makes an output above 8192 on a
|
||||
side (closes #241): that side is computed from the source's proportions, and
|
||||
nothing held it to the 8192 a requested side is held to, so `0x8192` of a
|
||||
3000x600 source was resized to 40960x8192 and encoded. The image processor now
|
||||
refuses such a size with `ErrOutputTooLarge` once it knows the source's
|
||||
dimensions, before resizing, and both image routes answer it 400 with the JSON
|
||||
error body. The refusal is not cached as a failed fetch, so the same source is
|
||||
still served at other sizes. `imgcache.MaxDimension` is now the image
|
||||
processor's `MaxOutputDimension`, so the two cannot differ.
|
||||
- 2026-10-13 the unused cache lifetime is removed (closes #69):
|
||||
`CacheConfig.CacheTTL`, which pixad set to `DefaultCacheTTL` (24 hours), was
|
||||
never read, so cached images never expired. The field and the constant are
|
||||
removed. Cached source and transformed images stay until eviction removes them
|
||||
to keep the cache within `cache_max_bytes`.
|
||||
- 2026-10-13 SVG sources are refused, and `orig` is never saved as JPEG in place
|
||||
of the source's own format (closes #68): the upstream fetch no longer accepts
|
||||
`image/svg+xml`, which libvips would render. Both image routes answer 415 for
|
||||
an upstream `Content-Type` that is not one of the source formats, SVG
|
||||
included, where they answered 500. Before decoding, the image processor
|
||||
refuses with `ErrUnsupportedInputFormat`, also answered 415, any input that
|
||||
govips' `DetermineImageType`, which picks the libvips loader from the first
|
||||
bytes, does not name as a source format. That catches an SVG whose first bytes
|
||||
pass the first-bytes check for another type, as an XML comment can make them
|
||||
do for AVIF; an SVG whose first bytes do not pass that check answers 500, as
|
||||
before, and so does a damaged image in a source format, whose first bytes
|
||||
`DetermineImageType` names. `orig` is always the source's own format, where a
|
||||
source the processor could not name was saved as JPEG. `README.md` no longer
|
||||
lists SVG. The SVG detection in `internal/magic` stays: the request path does
|
||||
not call it, and it goes with the unused code of
|
||||
https://git.eeqj.de/sneak/pixa/issues/168.
|
||||
- 2026-10-12 per-client limit on the image routes (closes #236): the new setting
|
||||
`image_requests_per_minute` (`PIXA_IMAGE_REQUESTS_PER_MINUTE`), default 600,
|
||||
is how many `GET` and `HEAD` requests one client may send to `/v1/image/` and
|
||||
`/v1/e/` together per minute, cache hits included; the next is answered 429
|
||||
with `Retry-After`. It uses the login limit's `RateLimit` middleware, so
|
||||
clients are told apart the same way, and runs after the CORS middleware, so a
|
||||
preflight is not counted. `0` turns the limit off; a negative or non-integer
|
||||
value aborts startup.
|
||||
- 2026-10-08 a request past its deadline no longer waits for the database to
|
||||
count it (closes #224). On a busy host, `TestService_Get_ReturnsByItsDeadline`
|
||||
failed because its request, past its deadline, still waited for its miss count
|
||||
to be written, a write with no deadline, which in pixad waits for the one
|
||||
database connection every request shares. Each count write now keeps the
|
||||
request's deadline but not its cancellation. A count not written by then is
|
||||
kept in memory, where `Stats` sees it, and one goroutine of the cache writes
|
||||
those counts, one write at a time, and once more at shutdown before the
|
||||
database closes. The test phase of the `Dockerfile` also runs at most 4 tests
|
||||
of a package at once (`go test -parallel 4`), where it ran as many as the host
|
||||
has CPUs: on a busy host they then wait so long to be scheduled that a test
|
||||
that times a request can fail. `-p`, how many packages are tested at once, is
|
||||
unchanged, as capping it also slows compiling.
|
||||
- 2026-10-08 every output format is saved with settings pixa sets on purpose
|
||||
(closes #232): each format has its own govips export, as JPEG XL does, in
|
||||
place of govips' generic `Export`, which sent libvips a zero for some settings
|
||||
it was not given. PNG gets libvips' default compression, 6, where it had none,
|
||||
and WebP libvips' default effort, 4, where it had 0. GIF was already at
|
||||
libvips' default effort, 7, and JPEG output is unchanged. AVIF is saved at
|
||||
effort 1, the lowest govips can set, where it had libvips' default, 4. With
|
||||
one libvips thread, an 8192x8192 image of random pixels, the worst case, takes
|
||||
about 51 seconds as AVIF at effort 1 and 43 as WebP at effort 4, against the
|
||||
default `downstream_timeout` of 60 seconds. On an image of milder noise, which
|
||||
AVIF at effort 1 saves in about 12 seconds, effort 4 takes minutes. AVIF is
|
||||
also saved with 8 bits per sample from a 16-bit source, which libvips would
|
||||
save with 12: a 16-bit 8192x8192 image of milder noise takes about 54 seconds
|
||||
at effort 1 with 12 bits, nearly all of the default `downstream_timeout`, and
|
||||
about 12 with 8.
|
||||
- 2026-10-08 JPEG XL is the default output (closes #222): a `/v1/image/` URL
|
||||
whose last segment is a size with no format, such as `800x600` or `orig`, is
|
||||
served as JPEG XL and signed as `jxl`, so it has the signature of the same URL
|
||||
ending in `.jxl`. An encrypted URL whose token holds no format is served as
|
||||
JPEG XL (`encurl.DefaultFormat`). The generator page selects JPEG XL until
|
||||
another format is chosen, and a form with an empty format, or none, makes a
|
||||
JPEG XL URL whose name ends in `.jxl`. The image processor no longer takes an
|
||||
empty format as `orig`: both routes give every request a format, and it
|
||||
refuses a request with none. `auto` still ends with JPEG.
|
||||
- 2026-10-08 JPEG XL as an input and output format (part of #222): a source
|
||||
whose bytes start with either JPEG XL signature, the bare codestream's `FF 0A`
|
||||
or the container's, is detected as `image/jxl`, which the upstream fetch
|
||||
accepts; `orig` of such a source is JPEG XL. The format `jxl` works in plain
|
||||
and encrypted URLs and on the generator page, served as `image/jxl` and cached
|
||||
like the other formats. `auto` chooses JPEG XL first when `Accept` names
|
||||
`image/jxl`. govips sends libvips a JPEG XL distance with every save, so
|
||||
libvips ignores the quality: pixa turns `q` into a distance with libvips' own
|
||||
formula, keeping 100 lossy, and removes the metadata from the image before
|
||||
saving, as govips cannot have libvips strip it from JPEG XL. libvips 8.16 and
|
||||
later still write an EXIF block of their own into JPEG XL, from the image's
|
||||
size, orientation and resolution, and fixed values; the image is upright and
|
||||
is given 72 dpi before the save, so the block holds nothing from the source.
|
||||
An image with an ICC profile is converted to sRGB before the JPEG XL save too,
|
||||
and a CMYK image with none is converted to sRGB, as libvips cannot save CMYK
|
||||
as JPEG XL. libvips' default effort, 7, is kept. JPEG XL is not yet the
|
||||
default output.
|
||||
- 2026-10-08 pixad runs on the Alpine release it is built on (closes #229): the
|
||||
runtime stage of the `Dockerfile` uses `alpine:3.22`, the release of the
|
||||
`golang:1.25.4-alpine3.22` image that the test phase and the build stage use,
|
||||
so all three have libvips 8.16, where the runtime image had 8.15.
|
||||
- 2026-10-08 requests no longer wait behind eviction queries that read a whole
|
||||
table (closes #227): the new `cache_usage` table holds the total cache usage,
|
||||
kept up to date by triggers on `source_content` and `variant_content` in the
|
||||
statement that adds, removes or resizes a row, and `UsageBytes` reads it. The
|
||||
reconciliation pass reads the content tables 1000 rows per query, sums them
|
||||
and corrects the total when it differs, unless a row changed while it summed.
|
||||
Source rows get `last_accessed_at` when added, so choosing source images to
|
||||
evict reads that column's index instead of sorting the whole table.
|
||||
- 2026-10-08 libvips' JPEG XL support is installed and required (part of #222):
|
||||
`script/bootstrap --cgo` installs `vips-jxl` when its package manager is apk,
|
||||
as Alpine's `vips` package lacks the support, and the runtime stage of the
|
||||
`Dockerfile` installs it too. `imgcache.NewService` fails, naming the fix,
|
||||
when `imageprocessor.CheckJPEGXLSupport` finds that libvips cannot load and
|
||||
save JPEG XL, so pixad does not start without it. A test saves an image as
|
||||
JPEG XL with govips and loads it back. JPEG XL is not yet a format pixa
|
||||
serves.
|
||||
- 2026-10-08 SQLite writes no longer fail with "database is locked" under load
|
||||
(closes #223): `internal/database` opens the database with one connection, so
|
||||
pixa's own reads and writes run on it one at a time instead of competing for
|
||||
@@ -794,6 +675,8 @@ P2: security: per-origin rate limiting
|
||||
|
||||
# Future Steps
|
||||
|
||||
- P2: security
|
||||
- per-origin rate limiting
|
||||
- P2: HTTP response handling
|
||||
- Last-Modified headers
|
||||
- P2: configuration
|
||||
|
||||
@@ -115,14 +115,6 @@ upstream_connections: 64
|
||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||
# max_concurrent_processing: 4
|
||||
|
||||
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
|
||||
# may send per minute; a request over the limit is answered 429 with a
|
||||
# Retry-After header. 0 turns the limit off. Clients are told apart as for
|
||||
# the login limit, so behind a CDN or proxy whose addresses are not in
|
||||
# trusted_proxies all requests arriving from one proxy or CDN address count
|
||||
# as one client: raise the limit there, or set 0. (default: 600)
|
||||
image_requests_per_minute: 600
|
||||
|
||||
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||
upstream_fetch_timeout: 30s
|
||||
|
||||
|
||||
+10
-39
@@ -34,7 +34,6 @@ const (
|
||||
DefaultUpstreamFetchTimeout = 30 * time.Second
|
||||
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
||||
DefaultDownstreamTimeout = 60 * time.Second
|
||||
DefaultImageRequestsPerMinute = 600
|
||||
)
|
||||
|
||||
// Configuration key names.
|
||||
@@ -62,7 +61,6 @@ const (
|
||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||
keyDownstreamTimeout = "downstream_timeout"
|
||||
keyImageRequestsPerMinute = "image_requests_per_minute"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
@@ -160,11 +158,6 @@ type Config struct {
|
||||
// of the per-request timeout middleware.
|
||||
DownstreamTimeout time.Duration
|
||||
|
||||
// ImageRequestsPerMinute is how many requests one client may make to
|
||||
// the image routes per minute; the next is refused with 429 Too Many
|
||||
// Requests. 0 turns the limit off.
|
||||
ImageRequestsPerMinute int
|
||||
|
||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||
// fetcher's dialer; the built-in ranges always apply.
|
||||
@@ -309,8 +302,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
||||
DownstreamTimeout: loader.durationVal(
|
||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
||||
ImageRequestsPerMinute: loader.intVal(
|
||||
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
BlockedNetworks: blockedNetworks,
|
||||
TrustedProxies: trustedProxies,
|
||||
@@ -320,7 +311,9 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
// The default for an omitted cache_max_bytes is worked out when
|
||||
// the cache opens; here we only record whether the operator set
|
||||
// the key explicitly.
|
||||
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
|
||||
if _, present := lookupValue(sc, keyCacheMaxBytes); present {
|
||||
c.CacheMaxBytesExplicit = true
|
||||
}
|
||||
|
||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||
// is a default: it applies only when db_url is omitted, never to an
|
||||
@@ -433,7 +426,7 @@ func isKnownConfigKey(key string) bool {
|
||||
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
||||
keyImageRequestsPerMinute, "env":
|
||||
"env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -468,7 +461,6 @@ func envVarNames() map[string]string {
|
||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
||||
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,14 +613,6 @@ func (c *Config) validate() error {
|
||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
||||
}
|
||||
|
||||
// Zero is valid (it turns the limit off); only negative values are
|
||||
// rejected.
|
||||
if c.ImageRequestsPerMinute < 0 {
|
||||
return fmt.Errorf("%s: value %d %w",
|
||||
settingName(keyImageRequestsPerMinute), c.ImageRequestsPerMinute,
|
||||
errMustNotBeNegative)
|
||||
}
|
||||
|
||||
err = c.validateUpstreamMaxResponseSize()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -641,9 +625,12 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
err = c.validateSentryDSN()
|
||||
if err != nil {
|
||||
return err
|
||||
if c.SentryDSN != "" {
|
||||
parsed, err := url.Parse(c.SentryDSN)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("%s: value %q is %w",
|
||||
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
||||
}
|
||||
}
|
||||
|
||||
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||
@@ -655,22 +642,6 @@ func (c *Config) validate() error {
|
||||
return c.validateAccessControlAllowOrigin()
|
||||
}
|
||||
|
||||
// validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme
|
||||
// and a host.
|
||||
func (c *Config) validateSentryDSN() error {
|
||||
if c.SentryDSN == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(c.SentryDSN)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("%s: value %q is %w",
|
||||
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
||||
// memory, and the image processor reads one byte past this limit, which
|
||||
|
||||
@@ -70,7 +70,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
||||
t.Setenv("PIXA_IMAGE_REQUESTS_PER_MINUTE", "30")
|
||||
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||
@@ -100,7 +99,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||
UpstreamConnectionsPerHost: 5,
|
||||
UpstreamConnections: 10,
|
||||
MaxConcurrentProcessing: 3,
|
||||
ImageRequestsPerMinute: 30,
|
||||
CacheMaxBytes: 1024,
|
||||
CacheMaxBytesExplicit: true,
|
||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// testImageRequestsPerMinuteVar is the variable that sets
|
||||
// image_requests_per_minute.
|
||||
const testImageRequestsPerMinuteVar = "PIXA_IMAGE_REQUESTS_PER_MINUTE"
|
||||
|
||||
// TestImageRequestsPerMinuteValues checks that an omitted
|
||||
// image_requests_per_minute is 600, and that 0, which turns the limit off,
|
||||
// and any larger whole number are used as given.
|
||||
func TestImageRequestsPerMinuteValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine)
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||
}
|
||||
|
||||
if c.ImageRequestsPerMinute != 600 {
|
||||
t.Errorf("ImageRequestsPerMinute = %d, want 600", c.ImageRequestsPerMinute)
|
||||
}
|
||||
|
||||
explicit := []struct {
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{"0", 0},
|
||||
{"30", 30},
|
||||
}
|
||||
|
||||
for _, tc := range explicit {
|
||||
c, err := configFromYAML(t,
|
||||
signingKeyLine+"image_requests_per_minute: "+tc.value+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("image_requests_per_minute: %s should load, got error: %v",
|
||||
tc.value, err)
|
||||
}
|
||||
|
||||
if c.ImageRequestsPerMinute != tc.want {
|
||||
t.Errorf("ImageRequestsPerMinute = %d, want %d",
|
||||
c.ImageRequestsPerMinute, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidImageRequestsPerMinuteAbortsStartup checks that a value that is
|
||||
// negative, not a whole number, or null aborts startup naming the key and
|
||||
// the value, and the variable too where the value could have come from it.
|
||||
func TestInvalidImageRequestsPerMinuteAbortsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := keyImageRequestsPerMinute
|
||||
|
||||
runAbortCases(t, []abortCase{
|
||||
{
|
||||
name: "negative",
|
||||
yaml: signingKeyLine + key + ": -1\n",
|
||||
wantErrSubstrings: []string{
|
||||
key, testImageRequestsPerMinuteVar, "value -1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not a number",
|
||||
yaml: signingKeyLine + key + ": plenty\n",
|
||||
wantErrSubstrings: []string{
|
||||
key, testImageRequestsPerMinuteVar, "plenty",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fractional",
|
||||
yaml: signingKeyLine + key + ": 2.5\n",
|
||||
wantErrSubstrings: []string{key, "2.5"},
|
||||
},
|
||||
{
|
||||
name: "explicit null",
|
||||
yaml: signingKeyLine + key + ": null\n",
|
||||
wantErrSubstrings: []string{key, nullValueText},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestImageRequestsPerMinuteFromEnvironment checks that the variable sets the
|
||||
// limit over the config file, and that an invalid value in it aborts startup
|
||||
// naming the variable and the value.
|
||||
func TestImageRequestsPerMinuteFromEnvironment(t *testing.T) {
|
||||
t.Setenv(testImageRequestsPerMinuteVar, "0")
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+"image_requests_per_minute: 30\n")
|
||||
if err != nil {
|
||||
t.Fatalf("limit from the environment should load: %v", err)
|
||||
}
|
||||
|
||||
if c.ImageRequestsPerMinute != 0 {
|
||||
t.Errorf("ImageRequestsPerMinute = %d, want 0 from the environment",
|
||||
c.ImageRequestsPerMinute)
|
||||
}
|
||||
|
||||
for _, value := range []string{"-5", "ten"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv(testImageRequestsPerMinuteVar, value)
|
||||
|
||||
_, err := configFromYAML(t, signingKeyLine)
|
||||
wantStartupError(t, err, testImageRequestsPerMinuteVar, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
|
||||
-- Source content blobs
|
||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
||||
-- last_accessed_at is NULL until the first LRU touch; eviction falls
|
||||
-- back to fetched_at for rows that have never been touched.
|
||||
CREATE TABLE IF NOT EXISTS source_content (
|
||||
content_hash TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
last_accessed_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||
ON source_content(last_accessed_at);
|
||||
@@ -40,8 +42,9 @@ CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(conte
|
||||
-- Processed variant blobs
|
||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
||||
-- sidecar with the content type). Tracked here (like source content
|
||||
-- blobs above) so total cache usage is known without a directory scan,
|
||||
-- and so LRU eviction has a timestamp to order on.
|
||||
-- blobs above) so total cache usage can be computed with a SUM query,
|
||||
-- never a directory scan, and so LRU eviction has a timestamp to order
|
||||
-- on.
|
||||
CREATE TABLE IF NOT EXISTS variant_content (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
@@ -52,74 +55,6 @@ CREATE TABLE IF NOT EXISTS variant_content (
|
||||
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||
ON variant_content(last_accessed_at);
|
||||
|
||||
-- Total cache usage: the sum of size_bytes over source_content and
|
||||
-- variant_content, kept by the triggers below in the same statement
|
||||
-- that adds, removes or resizes a row, so eviction reads this one row
|
||||
-- instead of summing both tables. Each trigger also adds one to
|
||||
-- change_count; the reconciliation pass, which sums both tables a page
|
||||
-- at a time, corrects total_size_bytes only if change_count did not
|
||||
-- move while it summed.
|
||||
CREATE TABLE IF NOT EXISTS cache_usage (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
change_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO cache_usage (id) VALUES (1);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS source_content_usage_insert
|
||||
AFTER INSERT ON source_content
|
||||
BEGIN
|
||||
UPDATE cache_usage
|
||||
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
|
||||
change_count = change_count + 1
|
||||
WHERE id = 1;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS source_content_usage_delete
|
||||
AFTER DELETE ON source_content
|
||||
BEGIN
|
||||
UPDATE cache_usage
|
||||
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
|
||||
change_count = change_count + 1
|
||||
WHERE id = 1;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS source_content_usage_update
|
||||
AFTER UPDATE OF size_bytes ON source_content
|
||||
BEGIN
|
||||
UPDATE cache_usage
|
||||
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes,
|
||||
change_count = change_count + 1
|
||||
WHERE id = 1;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS variant_content_usage_insert
|
||||
AFTER INSERT ON variant_content
|
||||
BEGIN
|
||||
UPDATE cache_usage
|
||||
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
|
||||
change_count = change_count + 1
|
||||
WHERE id = 1;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS variant_content_usage_delete
|
||||
AFTER DELETE ON variant_content
|
||||
BEGIN
|
||||
UPDATE cache_usage
|
||||
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
|
||||
change_count = change_count + 1
|
||||
WHERE id = 1;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS variant_content_usage_update
|
||||
AFTER UPDATE OF size_bytes ON variant_content
|
||||
BEGIN
|
||||
UPDATE cache_usage
|
||||
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes,
|
||||
change_count = change_count + 1
|
||||
WHERE id = 1;
|
||||
END;
|
||||
|
||||
-- Output/transformed content blobs
|
||||
-- Not written: transformed images are stored in cache/variants and
|
||||
-- tracked in variant_content above.
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// Default values for optional fields.
|
||||
const (
|
||||
DefaultQuality = 85
|
||||
DefaultFormat = imgcache.FormatJXL
|
||||
DefaultFormat = imgcache.FormatOriginal
|
||||
DefaultFitMode = imgcache.FitCover
|
||||
|
||||
// HKDF salt for URL encryption key derivation
|
||||
@@ -37,7 +37,7 @@ type Payload struct {
|
||||
SourceQuery string `cbor:"q,omitempty"` // optional
|
||||
Width int `cbor:"w,omitempty"` // 0 = original
|
||||
Height int `cbor:"ht,omitempty"` // 0 = original
|
||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
|
||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
|
||||
Quality int `cbor:"ql,omitempty"` // default: 85
|
||||
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
||||
|
||||
@@ -369,15 +369,10 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// Determine file extension for the trailing filename. A form with no
|
||||
// format makes a token with none, which is served as encurl.DefaultFormat.
|
||||
// Determine file extension for the trailing filename
|
||||
ext := format
|
||||
|
||||
switch format {
|
||||
case "":
|
||||
ext = string(encurl.DefaultFormat)
|
||||
case "orig", "auto":
|
||||
ext = "jpg"
|
||||
if ext == "" || ext == "orig" || ext == "auto" {
|
||||
ext = "jpg" // Default extension
|
||||
}
|
||||
|
||||
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
// Errors for an Accept header that an auto URL cannot be served for.
|
||||
var (
|
||||
errInvalidAccept = errors.New("invalid Accept header")
|
||||
errNotAcceptable = errors.New("not acceptable: auto serves " +
|
||||
"image/jxl, image/avif, image/webp or image/jpeg")
|
||||
errNotAcceptable = errors.New(
|
||||
"not acceptable: auto serves image/avif, image/webp or image/jpeg")
|
||||
)
|
||||
|
||||
// chooseAutoFormat replaces the format auto in req with the format
|
||||
@@ -51,11 +51,11 @@ func (s *Handlers) chooseAutoFormat(
|
||||
}
|
||||
|
||||
// formatForAccept returns the format an auto URL is served in for the Accept
|
||||
// header accept: JPEG XL when it names image/jxl, else AVIF when it names
|
||||
// image/avif, else WebP when it names image/webp, else JPEG when its most
|
||||
// specific entry of image/jpeg, image/* and */* allows it, or when it names
|
||||
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
|
||||
// clients that cannot show them send image/* and */* too.
|
||||
// header accept: AVIF when it names image/avif, else WebP when it names
|
||||
// image/webp, else JPEG when its most specific entry of image/jpeg, image/*
|
||||
// and */* allows it, or when it names nothing. A q of 0 refuses a format.
|
||||
// AVIF and WebP must be named, as clients that cannot show them send image/*
|
||||
// and */* too.
|
||||
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
||||
qualities, err := parseAccept(accept)
|
||||
if err != nil {
|
||||
@@ -66,10 +66,6 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
||||
return imgcache.FormatJPEG, nil
|
||||
}
|
||||
|
||||
if qualities["image/jxl"] > 0 {
|
||||
return imgcache.FormatJXL, nil
|
||||
}
|
||||
|
||||
if qualities["image/avif"] > 0 {
|
||||
return imgcache.FormatAVIF, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -72,21 +71,16 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
|
||||
//nolint:contextcheck // the eviction loop outlives OnStart; OnStop cancels it
|
||||
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.imgCache.StopEviction(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -106,6 +100,7 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
||||
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
||||
return imgcache.CacheConfig{
|
||||
StateDir: cfg.StateDir,
|
||||
CacheTTL: imgcache.DefaultCacheTTL,
|
||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||
MaxBytes: cfg.CacheMaxBytes,
|
||||
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
||||
@@ -127,9 +122,6 @@ func (s *Handlers) initImageService() error {
|
||||
// write-pressure passes. No-op 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
|
||||
|
||||
@@ -47,6 +47,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
||||
|
||||
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -18,8 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// HandleImage handles the main image proxy route:
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
|
||||
// /v1/image/<host>/<path>/<width>x<height>
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.refuseBlockedReferer(w, r) {
|
||||
@@ -206,16 +205,6 @@ func parseExpires(query url.Values) (time.Time, error) {
|
||||
return time.Unix(exp, 0), nil
|
||||
}
|
||||
|
||||
// HandleTooManyRequests answers 429 Too Many Requests with the JSON error
|
||||
// body the image routes send for their other errors. The image routes' rate
|
||||
// limit calls it for a request over the limit.
|
||||
func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
s.respondError(w, "too many requests, try again later",
|
||||
http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// respondImageError maps image retrieval errors to HTTP responses.
|
||||
func (s *Handlers) respondImageError(
|
||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||
@@ -247,22 +236,6 @@ func (s *Handlers) respondImageError(
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
|
||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
|
||||
s.respondError(w, "unsupported source format",
|
||||
http.StatusUnsupportedMediaType)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
|
||||
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
||||
"computed from its proportions is above %d",
|
||||
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ func newSignedHostServer(
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -161,14 +161,6 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
||||
errors.Is(err, imageprocessor.ErrTooManyImages):
|
||||
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)
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
"sneak.berlin/go/pixa/internal/signature"
|
||||
)
|
||||
|
||||
// requireJPEGXL requires that rec answers 200 with a JPEG XL image.
|
||||
func requireJPEGXL(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body %q",
|
||||
rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); got != jxlType {
|
||||
t.Errorf("Content-Type = %q, want %s", got, jxlType)
|
||||
}
|
||||
|
||||
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
|
||||
t.Errorf("body is %s, want jxl", vips.ImageTypes[got])
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageWithoutFormat_ServesJPEGXL verifies that a /v1/image/ URL whose
|
||||
// last segment is a size with no format, 50x50 or orig, answers JPEG XL.
|
||||
func TestImageWithoutFormat_ServesJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
route := newImageRoute(t, newPhotoFetcher(t, allowlistedHost))
|
||||
|
||||
for _, size := range []string{"50x50", "orig"} {
|
||||
target := "/v1/image/" + allowlistedHost + photoPath + "/" + size
|
||||
requireJPEGXL(t, sendGet(t, route, target))
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageWithoutFormat_SignedAsJXL verifies that a /v1/image/ URL with no
|
||||
// format is signed as jxl: the signature made for the URL ending in .jxl is
|
||||
// accepted for the same URL without .jxl.
|
||||
func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
|
||||
expires := time.Now().Add(time.Hour)
|
||||
|
||||
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
||||
SourceHost: signedHost,
|
||||
SourcePath: photoPath,
|
||||
Width: 50,
|
||||
Height: 50,
|
||||
Format: string(imgcache.FormatJXL),
|
||||
Quality: encurl.DefaultQuality,
|
||||
FitMode: string(imgcache.FitCover),
|
||||
Expires: expires,
|
||||
})
|
||||
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
|
||||
|
||||
for _, size := range []string{"50x50.jxl", "50x50"} {
|
||||
target := "/v1/image/" + signedHost + photoPath + "/" + size + query
|
||||
requireJPEGXL(t, sendGet(t, route, target))
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageEncWithoutFormat_ServesJPEGXL verifies that an encrypted URL whose
|
||||
// token holds no format answers JPEG XL.
|
||||
func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||
|
||||
token, err := h.encGen.Generate(&encurl.Payload{
|
||||
SourceHost: signedHost,
|
||||
SourcePath: photoPath,
|
||||
Width: 50,
|
||||
Height: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
requireJPEGXL(t, getEncToken(srv, token))
|
||||
}
|
||||
|
||||
// TestGeneratorPage_SelectsJPEGXL verifies that the generator page's format
|
||||
// choice is JPEG XL until another is chosen.
|
||||
func TestGeneratorPage_SelectsJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newCSRFTestRouter(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req.AddCookie(newSessionCookie(t, h))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if !strings.Contains(rec.Body.String(), `<option value="jxl" selected>`) {
|
||||
t.Errorf("generator page does not select JPEG XL: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratePost_NoFormat_MakesJPEGXLURL verifies that the generator form
|
||||
// sent with an empty format field, or with none, makes a URL whose name ends
|
||||
// in .jxl and which answers JPEG XL.
|
||||
func TestGeneratePost_NoFormat_MakesJPEGXLURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
photo := url.Values{
|
||||
sourceURLField: {"https://" + signedHost + photoPath},
|
||||
widthField: {"50"},
|
||||
heightField: {"50"},
|
||||
}
|
||||
|
||||
emptyFormat := maps.Clone(photo)
|
||||
emptyFormat.Set(formatField, "")
|
||||
|
||||
for name, form := range map[string]url.Values{
|
||||
"empty format field": emptyFormat,
|
||||
"no format field": photo,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||
|
||||
rec := generatePost(t, form)
|
||||
|
||||
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||
if match == nil {
|
||||
t.Fatalf("generator page shows no URL: %d %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
t.Logf("generated URL path: %s", match[1])
|
||||
|
||||
if !strings.HasSuffix(match[1], "/img.jxl") {
|
||||
t.Errorf("generated URL %s does not end in /img.jxl", match[1])
|
||||
}
|
||||
|
||||
imageRec := httptest.NewRecorder()
|
||||
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, match[1], nil))
|
||||
|
||||
requireJPEGXL(t, imageRec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// jxlType is the content type of JPEG XL.
|
||||
const jxlType = "image/jxl"
|
||||
|
||||
// TestFormatForAccept_JPEGXL verifies that the format auto chooses JPEG XL
|
||||
// when Accept names image/jxl, ahead of AVIF whatever their q, and AVIF when
|
||||
// Accept refuses JPEG XL with q=0.
|
||||
func TestFormatForAccept_JPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accept string
|
||||
want imgcache.ImageFormat
|
||||
}{
|
||||
{"JPEG XL-capable browser",
|
||||
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
|
||||
{"JPEG XL only", jxlType, imgcache.FormatJXL},
|
||||
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
||||
imgcache.FormatJXL},
|
||||
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := formatForAccept(tt.accept)
|
||||
if got != tt.want || err != nil {
|
||||
t.Errorf("formatForAccept(%q) = %q, %v, want %q",
|
||||
tt.accept, got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatAuto_JPEGXL requests an auto URL on each image route with an
|
||||
// Accept header that names image/jxl, and checks that the answer is a JPEG XL
|
||||
// image.
|
||||
func TestFormatAuto_JPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||
signedURL, encryptedURL := autoPhotoURLs(t, h)
|
||||
|
||||
for _, target := range []string{signedURL, encryptedURL} {
|
||||
rec := requestImage(t, srv, http.MethodGet, target,
|
||||
"image/jxl,image/avif,image/webp,*/*;q=0.8")
|
||||
gotType := rec.Header().Get("Content-Type")
|
||||
|
||||
if rec.Code != http.StatusOK || gotType != jxlType {
|
||||
t.Errorf("%s: %d %s, want 200 %s; body %s",
|
||||
target, rec.Code, gotType, jxlType, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
|
||||
t.Errorf("%s: body is %s, want jxl", target, vips.ImageTypes[got])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
|
||||
// 415 with a clear error when the upstream answers with a content type pixa
|
||||
// does not accept, such as SVG, and when orig is asked of a source in a
|
||||
// format the processor cannot name, wrapped as the service wraps them.
|
||||
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"}
|
||||
|
||||
for _, err := range []error{
|
||||
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
||||
httpfetcher.ErrInvalidContentType),
|
||||
fmt.Errorf("image processing failed: %w: unknown",
|
||||
imageprocessor.ErrUnsupportedInputFormat),
|
||||
} {
|
||||
plain := httptest.NewRecorder()
|
||||
h.respondImageError(plain, req, err)
|
||||
|
||||
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 {
|
||||
t.Errorf("%s for %v: status = %d, want %d",
|
||||
route, err, rec.Code, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
|
||||
route, err, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ const (
|
||||
contentTypeGIF = "image/gif"
|
||||
contentTypeWebP = "image/webp"
|
||||
contentTypeAVIF = "image/avif"
|
||||
contentTypeJXL = "image/jxl"
|
||||
contentTypeSVG = "image/svg+xml"
|
||||
contentTypeOctetStream = "application/octet-stream"
|
||||
)
|
||||
@@ -157,7 +156,7 @@ func DefaultConfig() *Config {
|
||||
contentTypeGIF,
|
||||
contentTypeWebP,
|
||||
contentTypeAVIF,
|
||||
contentTypeJXL,
|
||||
contentTypeSVG,
|
||||
},
|
||||
AllowHTTP: false,
|
||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package httpfetcher
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
||||
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
|
||||
// as image/jxl.
|
||||
func TestJPEGXLContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const jxlType = "image/jxl"
|
||||
|
||||
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
|
||||
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
|
||||
}
|
||||
|
||||
if got := detectContentTypeFromPath("images/photo.jxl"); got != jxlType {
|
||||
t.Errorf("detectContentTypeFromPath(photo.jxl) = %q, want %q", got, jxlType)
|
||||
}
|
||||
}
|
||||
@@ -109,8 +109,6 @@ func detectContentTypeFromPath(path string) string {
|
||||
return contentTypeWebP
|
||||
case strings.HasSuffix(path, ".avif"):
|
||||
return contentTypeAVIF
|
||||
case strings.HasSuffix(path, ".jxl"):
|
||||
return contentTypeJXL
|
||||
case strings.HasSuffix(path, ".svg"):
|
||||
return contentTypeSVG
|
||||
default:
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
|
||||
// with one side 0, which Process computes from the input's proportions, is
|
||||
// served when that side comes out at MaxOutputDimension and refused with
|
||||
// ErrOutputTooLarge when it would be above. A 4096x1 or 1x4096 input given
|
||||
// 2 for its short side has 8192 for its long side.
|
||||
func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputWidth, inputHeight int
|
||||
size Size
|
||||
wantErr error
|
||||
}{
|
||||
{"width at the maximum", 4096, 1, Size{Height: 2}, nil},
|
||||
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge},
|
||||
{"height at the maximum", 1, 4096, Size{Width: 2}, nil},
|
||||
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||
|
||||
result, err := New(Params{}).Process(t.Context(),
|
||||
bytes.NewReader(input),
|
||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
|
||||
if result != nil {
|
||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||
|
||||
if max(result.Width, result.Height) != MaxOutputDimension {
|
||||
t.Errorf("Process() made %dx%d, want a side of %d",
|
||||
result.Width, result.Height, MaxOutputDimension)
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Errorf("Process() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
|
||||
// is refused, as both image routes give every request a format before it is
|
||||
// processed.
|
||||
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := New(Params{}).Process(
|
||||
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{},
|
||||
)
|
||||
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
||||
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// processedSize runs input through Process and returns the output's size in
|
||||
// bytes.
|
||||
func processedSize(t *testing.T, input []byte, req *Request) int64 {
|
||||
t.Helper()
|
||||
|
||||
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Process() error = %v", err)
|
||||
}
|
||||
|
||||
_ = result.Content.Close()
|
||||
|
||||
return result.ContentLength
|
||||
}
|
||||
|
||||
// encodePNG encodes img as PNG with Go's encoder.
|
||||
func encodePNG(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test PNG: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// decode decodes input with vips, as Process does.
|
||||
func decode(t *testing.T, input []byte) *vips.ImageRef {
|
||||
t.Helper()
|
||||
|
||||
img, err := vips.NewImageFromBuffer(input)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode input: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(img.Close)
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
// TestImageProcessor_PNGIsCompressed verifies that a PNG output is
|
||||
// compressed: a flat 200x150 image, 90,000 bytes of raw pixels, comes out at
|
||||
// a small fraction of that.
|
||||
func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const width, height = 200, 150
|
||||
|
||||
flat := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
flat.Set(x, y, color.RGBA{R: 40, G: 120, B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
|
||||
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
|
||||
|
||||
const rawBytes = width * height * 3
|
||||
if size > rawBytes/10 {
|
||||
t.Errorf("PNG output is %d bytes, want under a tenth of its %d raw",
|
||||
size, rawBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_WebPAtDefaultEffort verifies that WebP is saved at
|
||||
// libvips' default effort, 4: the output is the size of the same image saved
|
||||
// at that effort.
|
||||
func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := createTestJPEG(t, 200, 150)
|
||||
|
||||
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
|
||||
|
||||
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||
StripMetadata: true,
|
||||
Quality: 85,
|
||||
ReductionEffort: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExportWebp() error = %v", err)
|
||||
}
|
||||
|
||||
if size != int64(len(want)) {
|
||||
t.Errorf("WebP output is %d bytes, want %d, the size at effort 4",
|
||||
size, len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_AVIFAtEffort1 verifies that AVIF is saved at effort 1
|
||||
// with 8 bits per sample: the output is the size of the same image saved
|
||||
// with those settings. The source is 16-bit, which libvips saves with 12
|
||||
// bits when it is not given a bit depth, and 640x480: on a small image,
|
||||
// such as 64x48, efforts 1 and 2 give the same output.
|
||||
func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const width, height = 640, 480
|
||||
|
||||
source := image.NewRGBA64(image.Rect(0, 0, width, height))
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
source.Set(x, y, color.RGBA64{
|
||||
R: uint16((x * 65535 / width) & 0xffff),
|
||||
G: uint16((y * 65535 / height) & 0xffff),
|
||||
B: 32768,
|
||||
A: 65535,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
input := encodePNG(t, source)
|
||||
|
||||
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
|
||||
|
||||
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||
StripMetadata: true,
|
||||
Quality: 85,
|
||||
Effort: 1,
|
||||
Bitdepth: 8,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExportAvif() error = %v", err)
|
||||
}
|
||||
|
||||
if size != int64(len(want)) {
|
||||
t.Errorf("AVIF output is %d bytes, want %d, the size at effort 1 "+
|
||||
"and 8 bits", size, len(want))
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
|
||||
// which scales the input to cover the requested size and so past it on one
|
||||
// side, is refused with ErrOutputTooLarge when that side would come out above
|
||||
// MaxOutputDimension, and otherwise makes the scaled size. A 1024x256 or
|
||||
// 256x1024 input scaled by 8 has 8192 for its long side.
|
||||
func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputWidth, inputHeight int
|
||||
size Size
|
||||
// wantWidth and wantHeight are the output's, 0 for a refusal
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0},
|
||||
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0},
|
||||
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048},
|
||||
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192},
|
||||
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||
|
||||
result, err := New(Params{}).Process(t.Context(),
|
||||
bytes.NewReader(input),
|
||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
||||
if result != nil {
|
||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||
}
|
||||
|
||||
if tt.wantWidth == 0 {
|
||||
if !errors.Is(err, ErrOutputTooLarge) {
|
||||
t.Errorf("Process() error = %v, want %v", err, ErrOutputTooLarge)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Process() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Width != tt.wantWidth || result.Height != tt.wantHeight {
|
||||
t.Errorf("Process() made %dx%d, want %dx%d",
|
||||
result.Width, result.Height, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,24 +37,6 @@ func initVips() {
|
||||
})
|
||||
}
|
||||
|
||||
// errNoJPEGXL is returned by CheckJPEGXLSupport.
|
||||
var errNoJPEGXL = errors.New("libvips lacks JPEG XL support: install " +
|
||||
"vips-jxl on Alpine, or use a libvips built with libjxl")
|
||||
|
||||
// CheckJPEGXLSupport returns an error, naming the fix, when libvips
|
||||
// cannot load and save JPEG XL.
|
||||
func CheckJPEGXLSupport() error {
|
||||
initVips()
|
||||
|
||||
// govips counts a format as supported when libvips has its loader;
|
||||
// libvips builds the JPEG XL loader and saver together.
|
||||
if !vips.IsTypeSupported(vips.ImageTypeJXL) {
|
||||
return errNoJPEGXL
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Format represents supported output image formats.
|
||||
type Format string
|
||||
|
||||
@@ -65,7 +47,6 @@ const (
|
||||
FormatPNG Format = "png"
|
||||
FormatWebP Format = "webp"
|
||||
FormatAVIF Format = "avif"
|
||||
FormatJXL Format = "jxl"
|
||||
FormatGIF Format = "gif"
|
||||
)
|
||||
|
||||
@@ -133,26 +114,10 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||
// configured byte limit.
|
||||
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||
|
||||
// MaxOutputDimension is the maximum width or height of an output image, and
|
||||
// so of a requested size (imgcache.MaxDimension).
|
||||
const MaxOutputDimension = 8192
|
||||
|
||||
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
||||
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
||||
// from the input's proportions, or with FitOutside, the side scaled past the
|
||||
// requested size.
|
||||
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
|
||||
|
||||
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
||||
// not supported.
|
||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||
|
||||
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
||||
// libvips reads the input's first bytes as another format than the source
|
||||
// formats, or as none: for example an SVG whose first bytes pass the
|
||||
// first-bytes check for AVIF.
|
||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||
|
||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||
// processed and none finishes within ProcessingWaitTimeout.
|
||||
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||
@@ -235,17 +200,6 @@ func (p *ImageProcessor) Process(
|
||||
return nil, ErrInputDataTooLarge
|
||||
}
|
||||
|
||||
// govips picks the libvips loader from the input's first bytes with
|
||||
// DetermineImageType. Input whose first bytes it reads as no source
|
||||
// format, such as SVG, which librsvg would render, is refused before
|
||||
// libvips reads it.
|
||||
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
||||
|
||||
sourceFormat, err := p.formatFromString(inputFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode image
|
||||
img, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
@@ -264,16 +218,16 @@ func (p *ImageProcessor) Process(
|
||||
origWidth := img.Width()
|
||||
origHeight := img.Height()
|
||||
|
||||
// Detect input format
|
||||
inputFormat := p.detectFormat(img)
|
||||
|
||||
// Validate input dimensions to prevent DoS via decompression bombs
|
||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||
return nil, ErrInputTooLarge
|
||||
}
|
||||
|
||||
// Determine target dimensions; too large ones are refused before resizing
|
||||
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Determine target dimensions
|
||||
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
||||
|
||||
// Resize if needed
|
||||
if targetWidth != origWidth || targetHeight != origHeight {
|
||||
@@ -283,10 +237,10 @@ func (p *ImageProcessor) Process(
|
||||
}
|
||||
}
|
||||
|
||||
// orig is the source's own format; encode refuses an empty format
|
||||
// Determine output format
|
||||
outputFormat := req.Format
|
||||
if outputFormat == FormatOriginal {
|
||||
outputFormat = sourceFormat
|
||||
if outputFormat == FormatOriginal || outputFormat == "" {
|
||||
outputFormat = p.formatFromString(inputFormat)
|
||||
}
|
||||
|
||||
// Encode to target format
|
||||
@@ -309,29 +263,21 @@ func (p *ImageProcessor) Process(
|
||||
|
||||
// targetDimensions calculates the output dimensions for a requested size,
|
||||
// scaling proportionally when only one dimension is given and keeping the
|
||||
// original dimensions when both are zero. A side scaled proportionally can
|
||||
// come out above MaxOutputDimension, which is ErrOutputTooLarge.
|
||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
||||
width, height := size.Width, size.Height
|
||||
|
||||
// original dimensions when both are zero.
|
||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
||||
switch {
|
||||
case width == 0 && height == 0:
|
||||
case size.Width == 0 && size.Height == 0:
|
||||
// Both are 0: keep original size
|
||||
return origWidth, origHeight, nil
|
||||
case width == 0:
|
||||
return origWidth, origHeight
|
||||
case size.Width == 0:
|
||||
// Only height specified: calculate width proportionally
|
||||
width = origWidth * height / origHeight
|
||||
case height == 0:
|
||||
return origWidth * size.Height / origHeight, size.Height
|
||||
case size.Height == 0:
|
||||
// Only width specified: calculate height proportionally
|
||||
height = origHeight * width / origWidth
|
||||
return size.Width, origHeight * size.Width / origWidth
|
||||
default:
|
||||
return size.Width, size.Height
|
||||
}
|
||||
|
||||
if width > MaxOutputDimension || height > MaxOutputDimension {
|
||||
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
|
||||
width, height, MaxOutputDimension)
|
||||
}
|
||||
|
||||
return width, height, nil
|
||||
}
|
||||
|
||||
// MIME types for the supported image formats.
|
||||
@@ -341,7 +287,6 @@ const (
|
||||
mimeGIF = "image/gif"
|
||||
mimeWebP = "image/webp"
|
||||
mimeAVIF = "image/avif"
|
||||
mimeJXL = "image/jxl"
|
||||
)
|
||||
|
||||
// SupportedInputFormats returns MIME types this processor can read.
|
||||
@@ -352,7 +297,6 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||
mimeGIF,
|
||||
mimeWebP,
|
||||
mimeAVIF,
|
||||
mimeJXL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +308,6 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
||||
FormatGIF,
|
||||
FormatWebP,
|
||||
FormatAVIF,
|
||||
FormatJXL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,8 +324,6 @@ func FormatToMIME(format Format) string {
|
||||
return mimeGIF
|
||||
case FormatAVIF:
|
||||
return mimeAVIF
|
||||
case FormatJXL:
|
||||
return mimeJXL
|
||||
case FormatOriginal:
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
@@ -438,9 +379,10 @@ func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
||||
}
|
||||
}
|
||||
|
||||
// detectFormat returns the format string for a govips image type.
|
||||
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||
switch imageType {
|
||||
// detectFormat returns the format string from a vips image.
|
||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
||||
format := img.Format()
|
||||
switch format {
|
||||
case vips.ImageTypeJPEG:
|
||||
return "jpeg"
|
||||
case vips.ImageTypePNG:
|
||||
@@ -451,20 +393,16 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||
return "webp"
|
||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||
return string(FormatAVIF)
|
||||
case vips.ImageTypeJXL:
|
||||
return string(FormatJXL)
|
||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||
vips.ImageTypeJP2K:
|
||||
vips.ImageTypeJP2K, vips.ImageTypeJXL:
|
||||
return "unknown"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// resize resizes the image according to the fit mode. With FitOutside one
|
||||
// side can come out larger than requested; when it would be above
|
||||
// MaxOutputDimension, resize returns ErrOutputTooLarge without resizing.
|
||||
// resize resizes the image according to the fit mode.
|
||||
func (p *ImageProcessor) resize(
|
||||
img *vips.ImageRef, width, height int, fit FitMode,
|
||||
) error {
|
||||
@@ -512,12 +450,6 @@ func (p *ImageProcessor) resize(
|
||||
newW := int(float64(imgW) * scale)
|
||||
newH := 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:
|
||||
@@ -535,6 +467,44 @@ func (p *ImageProcessor) encode(
|
||||
quality = defaultQuality
|
||||
}
|
||||
|
||||
var params vips.ExportParams
|
||||
|
||||
switch format {
|
||||
case FormatJPEG:
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeJPEG,
|
||||
Quality: quality,
|
||||
}
|
||||
|
||||
case FormatPNG:
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypePNG,
|
||||
}
|
||||
|
||||
case FormatGIF:
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeGIF,
|
||||
}
|
||||
|
||||
case FormatWebP:
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeWEBP,
|
||||
Quality: quality,
|
||||
}
|
||||
|
||||
case FormatAVIF:
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeAVIF,
|
||||
Quality: quality,
|
||||
}
|
||||
|
||||
case FormatOriginal:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||
}
|
||||
|
||||
// Stripping drops the ICC profile as well, and clients show an image
|
||||
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
||||
// libvips' built-in profile; govips' own sRGB path variable is set on
|
||||
@@ -546,166 +516,11 @@ func (p *ImageProcessor) encode(
|
||||
}
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatJPEG:
|
||||
return exportJPEG(img, quality)
|
||||
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
||||
// GIF, which carries none of them.
|
||||
params.StripMetadata = true
|
||||
|
||||
case FormatPNG:
|
||||
return exportPNG(img)
|
||||
|
||||
case FormatGIF:
|
||||
return exportGIF(img)
|
||||
|
||||
case FormatWebP:
|
||||
return exportWebP(img, quality)
|
||||
|
||||
case FormatAVIF:
|
||||
return exportAVIF(img, quality)
|
||||
|
||||
case FormatJXL:
|
||||
return exportJXL(img, quality)
|
||||
|
||||
case FormatOriginal:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||
}
|
||||
}
|
||||
|
||||
// govips sends libvips Go's zero value for some settings an export leaves
|
||||
// out, such as no compression at all for PNG, so each export below sets
|
||||
// every setting whose zero value is not what pixa wants. Stripping metadata
|
||||
// drops EXIF, XMP, IPTC and the ICC profile.
|
||||
|
||||
// exportJPEG encodes img as JPEG at quality, without metadata. The settings
|
||||
// it leaves out are at libvips' defaults.
|
||||
func exportJPEG(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||
output, _, err := img.ExportJpeg(&vips.JpegExportParams{
|
||||
StripMetadata: true,
|
||||
Quality: quality,
|
||||
})
|
||||
|
||||
return output, err
|
||||
}
|
||||
|
||||
// pngCompression is libvips' default PNG compression, from 0 (none) to 9.
|
||||
const pngCompression = 6
|
||||
|
||||
// exportPNG encodes img as PNG at libvips' default compression and row
|
||||
// filter, without metadata.
|
||||
func exportPNG(img *vips.ImageRef) ([]byte, error) {
|
||||
output, _, err := img.ExportPng(&vips.PngExportParams{
|
||||
StripMetadata: true,
|
||||
Compression: pngCompression,
|
||||
Filter: vips.PngFilterNone,
|
||||
})
|
||||
|
||||
return output, err
|
||||
}
|
||||
|
||||
// gifEffort is libvips' default GIF effort, from 1 to 10.
|
||||
const gifEffort = 7
|
||||
|
||||
// exportGIF encodes img as GIF at libvips' default effort. govips cannot
|
||||
// have libvips strip metadata from GIF, which carries none.
|
||||
func exportGIF(img *vips.ImageRef) ([]byte, error) {
|
||||
output, _, err := img.ExportGIF(&vips.GifExportParams{Effort: gifEffort})
|
||||
|
||||
return output, err
|
||||
}
|
||||
|
||||
// webpEffort is libvips' default WebP effort, from 0 (fastest) to 6.
|
||||
const webpEffort = 4
|
||||
|
||||
// exportWebP encodes img as lossy WebP at quality and libvips' default
|
||||
// effort, without metadata.
|
||||
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||
output, _, err := img.ExportWebp(&vips.WebpExportParams{
|
||||
StripMetadata: true,
|
||||
Quality: quality,
|
||||
ReductionEffort: webpEffort,
|
||||
})
|
||||
|
||||
return output, err
|
||||
}
|
||||
|
||||
// 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
|
||||
// seconds to save an 8192x8192 image of random pixels, the worst case,
|
||||
// against the default downstream_timeout of 60 seconds. On an image of
|
||||
// milder noise, which 1 saves in about 12 seconds, 2 takes nearly a minute
|
||||
// and libvips' default, 4, takes minutes.
|
||||
const avifEffort = 1
|
||||
|
||||
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
|
||||
// libvips would save a 16-bit image with 12, but at avifEffort that takes
|
||||
// about 54 seconds for a 16-bit 8192x8192 image of milder noise, nearly all
|
||||
// of the default downstream_timeout, and about 12 seconds with 8.
|
||||
const avifBitdepth = 8
|
||||
|
||||
// exportAVIF encodes img as lossy AVIF at quality, avifEffort and
|
||||
// avifBitdepth, without metadata.
|
||||
func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||
output, _, err := img.ExportAvif(&vips.AvifExportParams{
|
||||
StripMetadata: true,
|
||||
Quality: quality,
|
||||
Effort: avifEffort,
|
||||
Bitdepth: avifBitdepth,
|
||||
})
|
||||
|
||||
return output, err
|
||||
}
|
||||
|
||||
// jxlResolution is the resolution every JPEG XL image is saved with, in
|
||||
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
|
||||
// JPEG that names none.
|
||||
const jxlResolution = 72 / 25.4
|
||||
|
||||
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
|
||||
// and without metadata. govips sends libvips a distance, the JPEG XL
|
||||
// encoder's own measure of quality, along with the quality, and libvips then
|
||||
// uses the distance alone, so the quality is also given as a distance.
|
||||
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
|
||||
// PNG, but cannot save one as JPEG XL. encode has already converted
|
||||
// any image with an ICC profile to sRGB, so this is CMYK with none.
|
||||
if img.Interpretation() == vips.InterpretationCMYK {
|
||||
err := img.ToColorSpace(vips.InterpretationSRGB)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert CMYK to sRGB: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// govips cannot make libvips strip metadata from JPEG XL, so it is
|
||||
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
|
||||
// IPTC but keeps the ICC profile.
|
||||
err := img.RemoveMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = img.RemoveICCProfile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// libvips 8.16 and later still write an EXIF block of their own, from
|
||||
// the image's size, orientation and resolution, and fixed values. The
|
||||
// image is upright, so its orientation is 1, but its resolution is still
|
||||
// the source's.
|
||||
toSave, err := img.CopyChangingResolution(jxlResolution, jxlResolution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer toSave.Close()
|
||||
|
||||
params := vips.NewJxlExportParams()
|
||||
params.Quality = quality
|
||||
params.Distance = jxlDistance(quality)
|
||||
|
||||
output, _, err := toSave.ExportJxl(params)
|
||||
output, _, err := img.Export(¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -713,39 +528,20 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
|
||||
// distance, with the formula libvips and libjxl use for their own quality
|
||||
// setting, except that 100 stays lossy (distance 0.1) where libjxl makes it
|
||||
// lossless.
|
||||
//
|
||||
//nolint:mnd // the constants of that formula
|
||||
func jxlDistance(quality int) float64 {
|
||||
q := float64(quality)
|
||||
|
||||
if quality >= 30 {
|
||||
return 0.1 + (100-q)*0.09
|
||||
}
|
||||
|
||||
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
||||
}
|
||||
|
||||
// formatFromString converts a format string from detectFormat to Format. A
|
||||
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
||||
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
|
||||
// formatFromString converts a format string to Format.
|
||||
func (p *ImageProcessor) formatFromString(format string) Format {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return FormatJPEG, nil
|
||||
return FormatJPEG
|
||||
case "png":
|
||||
return FormatPNG, nil
|
||||
return FormatPNG
|
||||
case "gif":
|
||||
return FormatGIF, nil
|
||||
return FormatGIF
|
||||
case "webp":
|
||||
return FormatWebP, nil
|
||||
return FormatWebP
|
||||
case string(FormatAVIF):
|
||||
return FormatAVIF, nil
|
||||
case string(FormatJXL):
|
||||
return FormatJXL, nil
|
||||
return FormatAVIF
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
||||
return FormatJPEG
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// TestCheckJPEGXLSupport fails when libvips lacks JPEG XL support, as on
|
||||
// Alpine without the vips-jxl package.
|
||||
func TestCheckJPEGXLSupport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := CheckJPEGXLSupport()
|
||||
if err != nil {
|
||||
t.Fatalf("CheckJPEGXLSupport() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibvipsSavesAndLoadsJPEGXL saves an image as JPEG XL with govips and
|
||||
// loads it back. It fails when libvips lacks JPEG XL support, as on Alpine
|
||||
// without the vips-jxl package.
|
||||
func TestLibvipsSavesAndLoadsJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
img, err := vips.NewImageFromBuffer(createTestJPEG(t, 64, 48))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load test JPEG: %v", err)
|
||||
}
|
||||
|
||||
defer img.Close()
|
||||
|
||||
jxl, _, err := img.ExportJxl(vips.NewJxlExportParams())
|
||||
if err != nil {
|
||||
t.Fatalf("ExportJxl() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := vips.NewImageFromBuffer(jxl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load the JPEG XL image: %v", err)
|
||||
}
|
||||
|
||||
defer loaded.Close()
|
||||
|
||||
if loaded.Format() != vips.ImageTypeJXL {
|
||||
t.Errorf("loaded format = %s, want jxl", vips.ImageTypes[loaded.Format()])
|
||||
}
|
||||
|
||||
if loaded.Width() != 64 || loaded.Height() != 48 {
|
||||
t.Errorf("loaded size = %dx%d, want 64x48", loaded.Width(), loaded.Height())
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
||||
// checks the content type, and the format and size the output loads as.
|
||||
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
|
||||
|
||||
result, err := New(Params{}).Process(
|
||||
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Process() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = result.Content.Close() }()
|
||||
|
||||
if result.ContentType != "image/jxl" {
|
||||
t.Errorf("ContentType = %q, want image/jxl", result.ContentType)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(result.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
output, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load the output: %v", err)
|
||||
}
|
||||
|
||||
defer output.Close()
|
||||
|
||||
if output.Format() != vips.ImageTypeJXL {
|
||||
t.Errorf("output format = %s, want jxl", vips.ImageTypes[output.Format()])
|
||||
}
|
||||
|
||||
if output.Width() != 100 || output.Height() != 75 {
|
||||
t.Errorf("output size = %dx%d, want 100x75", output.Width(), output.Height())
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_JPEGXLQuality verifies that the quality reaches the JPEG
|
||||
// XL encoder: the same image comes out smaller at quality 30 than at 90.
|
||||
func TestImageProcessor_JPEGXLQuality(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := createTestJPEG(t, 400, 300)
|
||||
outputBytes := make(map[int]int64)
|
||||
|
||||
for _, quality := range []int{30, 90} {
|
||||
result, err := New(Params{}).Process(t.Context(),
|
||||
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
|
||||
if err != nil {
|
||||
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
||||
}
|
||||
|
||||
_ = result.Content.Close()
|
||||
outputBytes[quality] = result.ContentLength
|
||||
}
|
||||
|
||||
if outputBytes[30] >= outputBytes[90] {
|
||||
t.Errorf("%d bytes at quality 30, %d at 90, want fewer at 30",
|
||||
outputBytes[30], outputBytes[90])
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_JPEGXLDropsSourceEXIF verifies that none of the source's
|
||||
// EXIF reaches a JPEG XL output. libvips 8.16 and later write an EXIF block of
|
||||
// their own into JPEG XL (orientation, resolution, size, colour space and
|
||||
// fixed defaults), and govips cannot ask them to leave it out, so the test
|
||||
// looks for the source's fields rather than for no EXIF at all.
|
||||
func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input, err := os.ReadFile("testdata/gps-exif.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test JPEG: %v", err)
|
||||
}
|
||||
|
||||
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
|
||||
|
||||
for _, field := range []string{
|
||||
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
||||
"exif-ifd2-DateTimeOriginal", "exif-ifd3-GPSLatitude",
|
||||
"exif-ifd3-GPSLongitude",
|
||||
} {
|
||||
if value, found := exif[field]; found {
|
||||
t.Errorf("output has %s: %s", field, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_JPEGXLDropsSourceResolution verifies that the source's
|
||||
// resolution does not reach the EXIF block libvips 8.16 and later write into
|
||||
// JPEG XL. A JPEG XL image holds its resolution in that block alone, so the
|
||||
// resolution the output loads with is the block's.
|
||||
func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// dpi-300.jpg is a flat 8x8 grey image with a resolution of 300 dpi.
|
||||
input, err := os.ReadFile("testdata/dpi-300.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test JPEG: %v", err)
|
||||
}
|
||||
|
||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||
|
||||
// libvips gives the resolution in pixels per millimetre.
|
||||
xDPI := math.Round(output.ResX() * 25.4)
|
||||
yDPI := math.Round(output.ResY() * 25.4)
|
||||
|
||||
if xDPI == 300 || yDPI == 300 {
|
||||
t.Errorf("output resolution = %vx%v dpi, the source's", xDPI, yDPI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL
|
||||
// output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does
|
||||
// for PNG.
|
||||
func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// orientation-6.jpg is stored 16x8, red on the left and blue on the
|
||||
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
|
||||
// Upright it is 8x16, red on top and blue below.
|
||||
input, err := os.ReadFile("testdata/orientation-6.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test JPEG: %v", err)
|
||||
}
|
||||
|
||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||
|
||||
if output.Width() != 8 || output.Height() != 16 {
|
||||
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
||||
}
|
||||
|
||||
top, err := output.GetPoint(4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPoint() error = %v", err)
|
||||
}
|
||||
|
||||
bottom, err := output.GetPoint(4, 15)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPoint() error = %v", err)
|
||||
}
|
||||
|
||||
if top[0] <= top[2] || bottom[2] <= bottom[0] {
|
||||
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
|
||||
top, bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_JPEGXLConvertsWideGamutToSRGB verifies that a JPEG XL
|
||||
// output is converted to sRGB, as TestImageProcessor_ConvertsWideGamutToSRGB
|
||||
// does for PNG. It does not check for an ICC profile, as libvips reports one
|
||||
// for every JPEG XL image it loads.
|
||||
func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
|
||||
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
|
||||
input, err := os.ReadFile("testdata/display-p3.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test JPEG: %v", err)
|
||||
}
|
||||
|
||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||
|
||||
pixel, err := output.GetPoint(4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPoint() error = %v", err)
|
||||
}
|
||||
|
||||
want := []float64{255, 0, 0}
|
||||
for i := range want {
|
||||
if math.Abs(pixel[i]-want[i]) > 5 {
|
||||
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_JPEGXLFromCMYK verifies that a CMYK JPEG with no ICC
|
||||
// profile can be served as JPEG XL, in sRGB.
|
||||
func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// cmyk.jpg is a flat 8x8 CMYK image with no ICC profile, filled with
|
||||
// full cyan and no magenta, yellow or black.
|
||||
input, err := os.ReadFile("testdata/cmyk.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test JPEG: %v", err)
|
||||
}
|
||||
|
||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||
|
||||
if output.Bands() != 3 {
|
||||
t.Fatalf("output has %d bands, want 3", output.Bands())
|
||||
}
|
||||
|
||||
pixel, err := output.GetPoint(4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPoint() error = %v", err)
|
||||
}
|
||||
|
||||
// Cyan in sRGB: little red, much green and blue.
|
||||
if pixel[0] > 50 || pixel[1] < 100 || pixel[2] < 200 {
|
||||
t.Errorf("pixel = %v, want cyan", pixel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJXLDistance verifies the JPEG XL distance for a few qualities: 90 is
|
||||
// distance 1, the encoder's own default, and 100 stays lossy.
|
||||
func TestJXLDistance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
quality int
|
||||
want float64
|
||||
}{
|
||||
{quality: 100, want: 0.1},
|
||||
{quality: 90, want: 1},
|
||||
{quality: 30, want: 6.4},
|
||||
{quality: 20, want: 9.0667},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := jxlDistance(tt.quality)
|
||||
if math.Abs(got-tt.want) > 0.0001 {
|
||||
t.Errorf("jxlDistance(%d) = %v, want %v", tt.quality, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||
// input, which is not a source format, is refused with
|
||||
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
|
||||
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
img, err := vips.NewImageFromBuffer(createTestPNG(t, 20, 20))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode test PNG: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(img.Close)
|
||||
|
||||
tiff, _, err := img.ExportTiff(vips.NewTiffExportParams())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save test TIFF: %v", err)
|
||||
}
|
||||
|
||||
result, err := New(Params{}).Process(
|
||||
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal},
|
||||
)
|
||||
if result != nil {
|
||||
t.Logf("Process() returned %s", result.ContentType)
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrUnsupportedInputFormat) {
|
||||
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedInputFormat)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
|
||||
// the six source formats, saved by the processor itself, is loaded and saved
|
||||
// again in its own format (orig).
|
||||
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proc := New(Params{})
|
||||
|
||||
for _, format := range []Format{
|
||||
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
|
||||
} {
|
||||
saved, err := proc.Process(t.Context(),
|
||||
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
|
||||
if err != nil {
|
||||
t.Fatalf("saving the test image as %s: %v", format, err)
|
||||
}
|
||||
|
||||
source, err := io.ReadAll(saved.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("reading the %s image: %v", format, err)
|
||||
}
|
||||
|
||||
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
||||
&Request{Format: FormatOriginal})
|
||||
if err != nil {
|
||||
t.Errorf("orig of the %s image: %v", format, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
t.Logf("orig of the %s image: %s", format, result.ContentType)
|
||||
|
||||
if result.ContentType != FormatToMIME(format) {
|
||||
t.Errorf("orig of the %s image is %s, want %s",
|
||||
format, result.ContentType, FormatToMIME(format))
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 352 B |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 799 B |
+18
-100
@@ -11,8 +11,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
@@ -35,6 +33,7 @@ const metaCacheSize = 10000
|
||||
// CacheConfig holds cache configuration.
|
||||
type CacheConfig struct {
|
||||
StateDir string
|
||||
CacheTTL time.Duration
|
||||
NegativeTTL time.Duration
|
||||
|
||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||
@@ -80,27 +79,6 @@ type Cache struct {
|
||||
evictionDone chan struct{}
|
||||
evictionCancel context.CancelFunc
|
||||
|
||||
// The pending counts: hits, misses, upstream fetches and transforms
|
||||
// whose write to the database missed its request's deadline, kept
|
||||
// here until they are written by the goroutine that
|
||||
// StartPendingCountWrites starts. As for eviction, the channels are
|
||||
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
||||
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
||||
// set by StartPendingCountWrites, cancels its context, which tells it
|
||||
// to finish.
|
||||
pendingHits atomic.Int64
|
||||
pendingMisses atomic.Int64
|
||||
pendingUpstreamFetches atomic.Int64
|
||||
pendingUpstreamFetchBytes atomic.Int64
|
||||
pendingTransforms atomic.Int64
|
||||
pendingCountsAdded chan struct{}
|
||||
pendingCountsDone chan struct{}
|
||||
pendingCountsCancel context.CancelFunc
|
||||
|
||||
// Held through each write of the pending counts, and by
|
||||
// readHitAndMissCounts to read them
|
||||
pendingCountsWriteMutex sync.Mutex
|
||||
|
||||
// metaCache holds the content types of the variants most recently
|
||||
// stored or served, so a hit does not read the variant's .meta file.
|
||||
// It never stands in for the variant file, which is always opened.
|
||||
@@ -118,17 +96,6 @@ type Cache struct {
|
||||
// deterministically pause inside that window to exercise
|
||||
// concurrent stores against it; production code leaves it nil.
|
||||
evictSourceBlobTestHook func(ContentHash)
|
||||
|
||||
// reconciliationPageSize is the most rows one read of a content table
|
||||
// returns in the reconciliation pass and in Stats. newCache sets it to
|
||||
// defaultReconciliationPageSize; tests set it smaller.
|
||||
reconciliationPageSize int
|
||||
|
||||
// reconciliationReadTestHook, when set, is called after each of those
|
||||
// reads with the number of rows the read covered, so tests can check
|
||||
// that no read covers more than one page; production code leaves it
|
||||
// nil.
|
||||
reconciliationReadTestHook func(rows int)
|
||||
}
|
||||
|
||||
// NewCache creates a new cache instance.
|
||||
@@ -160,11 +127,6 @@ func newCache(
|
||||
evictionDone: make(chan struct{}),
|
||||
metaCache: metaCache,
|
||||
contentLocks: newContentLock(),
|
||||
|
||||
pendingCountsAdded: make(chan struct{}, 1),
|
||||
pendingCountsDone: make(chan struct{}),
|
||||
|
||||
reconciliationPageSize: defaultReconciliationPageSize,
|
||||
}
|
||||
|
||||
if c.disabled {
|
||||
@@ -499,14 +461,18 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
|
||||
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
var stats CacheStats
|
||||
|
||||
err := c.readHitAndMissCounts(ctx, &stats)
|
||||
// Fetch hit/miss counts from the stats table
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT hit_count, miss_count
|
||||
FROM cache_stats WHERE id = 1
|
||||
`).Scan(&stats.HitCount, &stats.MissCount)
|
||||
|
||||
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.
|
||||
// Count and size the cached source images and processed variants. 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)
|
||||
@@ -516,7 +482,7 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||
}
|
||||
|
||||
stats.TotalSizeBytes, err = c.sumContentSizeBytes(ctx)
|
||||
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||
}
|
||||
@@ -531,30 +497,19 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
}
|
||||
|
||||
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
||||
// methods, it writes to the database with ctx's deadline but not its
|
||||
// cancellation, so an ended request is still counted but does not wait for
|
||||
// the database past its deadline; a count not written by then becomes a
|
||||
// pending count (see StartPendingCountWrites).
|
||||
// fetchBytes bytes, as IncrementUpstreamFetch does.
|
||||
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 {
|
||||
pendingCount = &c.pendingHits
|
||||
|
||||
_, err = c.db.ExecContext(countCtx, `
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
UPDATE cache_stats
|
||||
SET hit_count = hit_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
} else {
|
||||
_, err = c.db.ExecContext(countCtx, `
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
UPDATE cache_stats
|
||||
SET miss_count = miss_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
@@ -562,10 +517,7 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
||||
`)
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
c.addPendingCount(pendingCount, 1)
|
||||
case err != nil:
|
||||
if err != nil {
|
||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
||||
}
|
||||
|
||||
@@ -579,22 +531,14 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
||||
return
|
||||
}
|
||||
|
||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.db.ExecContext(countCtx, `
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
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:
|
||||
if err != nil {
|
||||
c.log.Warn("failed to count upstream fetch",
|
||||
"fetch_bytes", fetchBytes, "error", err)
|
||||
}
|
||||
@@ -602,43 +546,17 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
||||
|
||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.db.ExecContext(countCtx, `
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
UPDATE cache_stats
|
||||
SET transform_count = transform_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
c.addPendingCount(&c.pendingTransforms, 1)
|
||||
case err != nil:
|
||||
if err != nil {
|
||||
c.log.Warn("failed to count transform", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
||||
// cache_stats row plus the pending hits and misses (see
|
||||
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
|
||||
// write of the pending counts falls between the two reads.
|
||||
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
|
||||
c.pendingCountsWriteMutex.Lock()
|
||||
defer c.pendingCountsWriteMutex.Unlock()
|
||||
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT hit_count, miss_count
|
||||
FROM cache_stats WHERE id = 1
|
||||
`).Scan(&stats.HitCount, &stats.MissCount)
|
||||
|
||||
stats.HitCount += c.pendingHits.Load()
|
||||
stats.MissCount += c.pendingMisses.Load()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||
// hold: it reads the content type from the variant's .meta file and
|
||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||
|
||||
@@ -102,6 +102,7 @@ func setupTestCache(t *testing.T) *Cache {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -240,6 +241,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
// Very short negative TTL for testing
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 1 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
|
||||
// cache content, then drops both content tables. UsageBytes must still
|
||||
// report what the tables held, and an eviction pass under the limit must
|
||||
// still succeed: neither may sum the tables.
|
||||
func TestEvictionReadsTheUsageTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
kept := storeEvictionTestSource(t, cache, "total.example.com", "/kept.jpg",
|
||||
bytes.Repeat([]byte{0x71}, 1000))
|
||||
evicted := storeEvictionTestSource(t, cache, "total.example.com", "/evicted.jpg",
|
||||
bytes.Repeat([]byte{0x72}, 700))
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x73}, 500))
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x74}, 300))
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||
bytes.Repeat([]byte{0x75}, 200))
|
||||
|
||||
// pixa never changes a source's size, but a statement that does must
|
||||
// change the total too.
|
||||
_, err := cache.db.ExecContext(ctx,
|
||||
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
|
||||
string(kept))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change the source's size: %v", err)
|
||||
}
|
||||
|
||||
err = cache.evictSourceBlob(ctx, evicted)
|
||||
if err != nil {
|
||||
t.Fatalf("evictSourceBlob failed: %v", err)
|
||||
}
|
||||
|
||||
err = cache.evictVariant(ctx, testVariantKeyTwo)
|
||||
if err != nil {
|
||||
t.Fatalf("evictVariant failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = cache.db.ExecContext(ctx,
|
||||
`DROP TABLE source_content; DROP TABLE variant_content`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to drop the content tables: %v", err)
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
t.Logf("UsageBytes() = %d, error = %v", usage, err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes() error = %v, want nil: it read a content table", err)
|
||||
}
|
||||
|
||||
if usage != 1200 {
|
||||
t.Errorf("UsageBytes() = %d, want 1200 (900 + 300)", usage)
|
||||
}
|
||||
|
||||
err = cache.EvictToLimit(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("EvictToLimit() error = %v, want nil: it read a content table", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconciliationCorrectsTheUsageTotal sets the total cache usage to a
|
||||
// wrong value and checks that a reconciliation pass, which sums both
|
||||
// content tables, puts it right.
|
||||
func TestReconciliationCorrectsTheUsageTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
storeEvictionTestSource(t, cache, "total.example.com", "/a.jpg",
|
||||
bytes.Repeat([]byte{0x76}, 1000))
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x77}, 500))
|
||||
|
||||
_, err := cache.db.ExecContext(ctx,
|
||||
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set a wrong total: %v", err)
|
||||
}
|
||||
|
||||
err = cache.reconcileAccounting(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if usage != 1500 {
|
||||
t.Errorf("UsageBytes() after reconciliation = %d, want 1500 (1000 + 500)",
|
||||
usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageTotalNotCorrectedFromAnOlderSum stores a variant after the
|
||||
// change count was read, then offers a sum taken before the store as the
|
||||
// correction: the total must keep the stored variant, as that sum may
|
||||
// have missed it.
|
||||
func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
var changeCount int64
|
||||
|
||||
err := cache.db.QueryRowContext(ctx,
|
||||
`SELECT change_count FROM cache_usage WHERE id = 1`,
|
||||
).Scan(&changeCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the change count: %v", err)
|
||||
}
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x78}, 500))
|
||||
|
||||
corrected, err := cache.correctUsageTotal(ctx, 0, changeCount)
|
||||
if err != nil {
|
||||
t.Fatalf("correctUsageTotal failed: %v", err)
|
||||
}
|
||||
|
||||
if corrected {
|
||||
t.Error("correctUsageTotal() = true, want false: content changed since the sum")
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if usage != 500 {
|
||||
t.Errorf("UsageBytes() = %d, want 500", usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconciliationReadsAPageAtATime stores five source images and five
|
||||
// variants, sets the page size to two rows and runs a reconciliation
|
||||
// pass. No read the pass makes of a content table, to check its rows or
|
||||
// to sum them, may cover more than two rows, so a request's query waits
|
||||
// for one page at most. Between them the reads must still cover every
|
||||
// row of both tables twice, once to check it and once to sum it, and the
|
||||
// sum must put a wrong total right.
|
||||
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
const pageSize = 2
|
||||
|
||||
cache.reconciliationPageSize = pageSize
|
||||
|
||||
// Sources of 100 bytes and variants of 10, five of each.
|
||||
for i := range 5 {
|
||||
content := []byte(strconv.Itoa(i))
|
||||
|
||||
storeEvictionTestSource(t, cache, "pages.example.com",
|
||||
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
|
||||
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)),
|
||||
bytes.Repeat(content, 10))
|
||||
}
|
||||
|
||||
_, err := cache.db.ExecContext(ctx,
|
||||
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set a wrong total: %v", err)
|
||||
}
|
||||
|
||||
var rowsPerRead []int
|
||||
|
||||
cache.reconciliationReadTestHook = func(rows int) {
|
||||
rowsPerRead = append(rowsPerRead, rows)
|
||||
}
|
||||
|
||||
err = cache.reconcileAccounting(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("rows covered by each read, in order: %v", rowsPerRead)
|
||||
|
||||
coveredRows := 0
|
||||
|
||||
for _, rows := range rowsPerRead {
|
||||
if rows > pageSize {
|
||||
t.Errorf("a read covered %d rows, want at most %d", rows, pageSize)
|
||||
}
|
||||
|
||||
coveredRows += rows
|
||||
}
|
||||
|
||||
// Ten rows, each read once to check it and once to sum it.
|
||||
if coveredRows != 20 {
|
||||
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if usage != 550 {
|
||||
t.Errorf("UsageBytes() after reconciliation = %d, want 550 (5*100 + 5*10)",
|
||||
usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceEvictionCandidatesComeFromTheIndex checks that the query
|
||||
// choosing source images to evict reads source_content in last access
|
||||
// order from its index, instead of reading and sorting the whole table.
|
||||
func TestSourceEvictionCandidatesComeFromTheIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
rows, err := cache.db.QueryContext(t.Context(),
|
||||
`EXPLAIN QUERY PLAN `+sourceCandidatesQuery, evictionBatchSize)
|
||||
if err != nil {
|
||||
t.Fatalf("EXPLAIN QUERY PLAN failed: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var plan []string
|
||||
|
||||
for rows.Next() {
|
||||
var id, parent, unused int
|
||||
|
||||
var detail string
|
||||
|
||||
err := rows.Scan(&id, &parent, &unused, &detail)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to scan the query plan: %v", err)
|
||||
}
|
||||
|
||||
plan = append(plan, detail)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("query plan iteration failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("query plan: %q", plan)
|
||||
|
||||
if !strings.Contains(strings.Join(plan, "\n"), "idx_source_content_last_accessed") {
|
||||
t.Errorf("the source candidate query does not use "+
|
||||
"idx_source_content_last_accessed; plan: %q", plan)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (c *Cache) computeDefaultMaxBytes(
|
||||
}
|
||||
|
||||
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
|
||||
//nolint:gosec // G115: UsageBytes returns the total cache usage, never negative
|
||||
//nolint:gosec // G115: UsageBytes sums file sizes, never negative
|
||||
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
|
||||
|
||||
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||
|
||||
+76
-250
@@ -21,12 +21,6 @@ const DefaultEvictionInterval = 5 * time.Minute
|
||||
// and source blobs) one eviction pass fetches from the database.
|
||||
const evictionBatchSize = 100
|
||||
|
||||
// defaultReconciliationPageSize is the most rows one read of the
|
||||
// reconciliation pass returns, unless a test sets
|
||||
// Cache.reconciliationPageSize smaller. Each read is a query of its own,
|
||||
// so a request waits for one page at most, however large the cache is.
|
||||
const defaultReconciliationPageSize = 1000
|
||||
|
||||
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
||||
// crashed write) must be before reconciliation removes it. Fresh temp
|
||||
// files may still belong to an in-flight store.
|
||||
@@ -51,9 +45,7 @@ const fallbackContentType = "application/octet-stream"
|
||||
|
||||
// UsageBytes returns the total number of bytes of cache content
|
||||
// tracked in the database (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.
|
||||
// variants). It never scans the cache directories.
|
||||
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||
if c.disabled {
|
||||
return 0, nil
|
||||
@@ -61,11 +53,12 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||
|
||||
var total int64
|
||||
|
||||
err := c.db.QueryRowContext(ctx,
|
||||
`SELECT total_size_bytes FROM cache_usage WHERE id = 1`,
|
||||
).Scan(&total)
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
||||
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
||||
`).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read cache usage: %w", err)
|
||||
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
@@ -243,19 +236,16 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// sourceCandidatesQuery selects the least recently used source blobs. It
|
||||
// orders by the last_accessed_at column itself, not by an expression, so
|
||||
// SQLite reads the rows in order from that column's index instead of
|
||||
// sorting the whole table.
|
||||
const sourceCandidatesQuery = `
|
||||
SELECT content_hash, size_bytes, last_accessed_at
|
||||
FROM source_content
|
||||
ORDER BY last_accessed_at ASC, content_hash ASC
|
||||
LIMIT ?`
|
||||
|
||||
// sourceCandidates returns the least recently used source blobs.
|
||||
// sourceCandidates returns the least recently used source blobs. Rows
|
||||
// written before the LRU column existed fall back to fetched_at.
|
||||
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||
rows, err := c.db.QueryContext(ctx, sourceCandidatesQuery, evictionBatchSize)
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT content_hash, size_bytes,
|
||||
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
||||
FROM source_content
|
||||
ORDER BY lru ASC, content_hash ASC
|
||||
LIMIT ?
|
||||
`, evictionBatchSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
||||
}
|
||||
@@ -542,13 +532,11 @@ func (c *Cache) runReconciliationPass(ctx context.Context) {
|
||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||
// insert under transient DB contention), drops accounting rows whose
|
||||
// files are missing, removes source blob files the database does not
|
||||
// know (and rows whose files are gone), sweeps stale temp files left
|
||||
// behind by crashed writes, and last checks the total cache usage
|
||||
// against the tables. It reads the tables a page at a time. Running it
|
||||
// periodically, not just once, bounds how long such drift can
|
||||
// accumulate unaccounted for on a long-running process to one eviction
|
||||
// interval. Once ctx is cancelled, it stops at the next file or row and
|
||||
// returns ctx's error.
|
||||
// know (and rows whose files are gone), and sweeps stale temp files
|
||||
// left behind by crashed writes. Running it periodically, not just
|
||||
// once, bounds how long such drift can accumulate unaccounted for on a
|
||||
// long-running process to one eviction interval. Once ctx is cancelled,
|
||||
// it stops at the next file or row and returns ctx's error.
|
||||
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||
if c.disabled {
|
||||
return nil
|
||||
@@ -574,7 +562,7 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.reconcileUsageTotal(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileVariantFiles walks the variant storage directory, adopting
|
||||
@@ -669,63 +657,46 @@ func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
||||
// reconcileVariantRows drops accounting rows whose variant files are
|
||||
// missing, so the database never references deleted content.
|
||||
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||
var after VariantKey
|
||||
|
||||
for {
|
||||
keys, err := c.variantKeysAfter(ctx, after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.reconciliationReadTestHook != nil {
|
||||
c.reconciliationReadTestHook(len(keys))
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if c.variants.Exists(key) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
||||
}
|
||||
|
||||
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||
}
|
||||
|
||||
after = keys[len(keys)-1]
|
||||
keys, err := c.allVariantKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if c.variants.Exists(key) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
||||
}
|
||||
|
||||
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// variantKeysAfter returns, in order, up to c.reconciliationPageSize
|
||||
// tracked variant cache keys that sort after the given one.
|
||||
func (c *Cache) variantKeysAfter(
|
||||
ctx context.Context, after VariantKey,
|
||||
) ([]VariantKey, error) {
|
||||
return queryStringColumn[VariantKey](ctx, c.db, `
|
||||
SELECT cache_key FROM variant_content
|
||||
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
|
||||
`, "variant keys", "variant key", string(after), c.reconciliationPageSize)
|
||||
// allVariantKeys returns every tracked variant cache key.
|
||||
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
|
||||
return queryStringColumn[VariantKey](ctx, c.db,
|
||||
`SELECT cache_key FROM variant_content`, "variant keys", "variant key")
|
||||
}
|
||||
|
||||
// queryStringColumn runs a single-column query with args and returns the
|
||||
// column values as T. plural names the set for the query and scan
|
||||
// failure messages; singular names one row for the scan and iteration
|
||||
// failure messages.
|
||||
// queryStringColumn runs a single-column query and returns the column
|
||||
// values as T. plural names the set for the query and scan failure
|
||||
// messages; singular names one row for the scan and iteration failure
|
||||
// messages.
|
||||
func queryStringColumn[T ~string](
|
||||
ctx context.Context, db *sql.DB, query, plural, singular string, args ...any,
|
||||
ctx context.Context, db *sql.DB, query, plural, singular string,
|
||||
) ([]T, error) {
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
rows, err := db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
||||
}
|
||||
@@ -820,183 +791,38 @@ func (c *Cache) removeUntrackedSourceFile(
|
||||
// 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
|
||||
hashes, err := c.allSourceContentHashes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
hashes, err := c.sourceContentHashesAfter(ctx, after)
|
||||
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
|
||||
}
|
||||
|
||||
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]
|
||||
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// last row the page has no rows and the key is NULL.
|
||||
const (
|
||||
sourceSizePageQuery = `
|
||||
SELECT MAX(content_hash), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||
FROM (
|
||||
SELECT content_hash, size_bytes FROM source_content
|
||||
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
||||
)`
|
||||
variantSizePageQuery = `
|
||||
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||
FROM (
|
||||
SELECT cache_key, size_bytes FROM variant_content
|
||||
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
|
||||
)`
|
||||
)
|
||||
|
||||
// sumContentSizeBytes sums size_bytes over both content tables, a page
|
||||
// of rows per query.
|
||||
func (c *Cache) sumContentSizeBytes(ctx context.Context) (int64, error) {
|
||||
sourceBytes, err := c.sumSizeBytesInPages(ctx, sourceSizePageQuery)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
variantBytes, err := c.sumSizeBytesInPages(ctx, variantSizePageQuery)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return sourceBytes + variantBytes, nil
|
||||
}
|
||||
|
||||
// sumSizeBytesInPages runs pageQuery, one of the size page queries
|
||||
// above, from the first page to the last and adds up the page sums.
|
||||
func (c *Cache) sumSizeBytesInPages(
|
||||
ctx context.Context, pageQuery string,
|
||||
) (int64, error) {
|
||||
var total int64
|
||||
|
||||
after := ""
|
||||
|
||||
for {
|
||||
var lastKey sql.NullString
|
||||
|
||||
var pageBytes int64
|
||||
|
||||
var pageRows int
|
||||
|
||||
err := c.db.QueryRowContext(ctx, pageQuery, after, c.reconciliationPageSize).
|
||||
Scan(&lastKey, &pageBytes, &pageRows)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum cache content sizes: %w", err)
|
||||
}
|
||||
|
||||
if c.reconciliationReadTestHook != nil {
|
||||
c.reconciliationReadTestHook(pageRows)
|
||||
}
|
||||
|
||||
if pageRows == 0 {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
total += pageBytes
|
||||
after = lastKey.String
|
||||
}
|
||||
}
|
||||
|
||||
// reconcileUsageTotal checks the total cache usage the database keeps
|
||||
// against size_bytes summed over both content tables, and corrects the
|
||||
// total when they differ.
|
||||
func (c *Cache) reconcileUsageTotal(ctx context.Context) error {
|
||||
var totalBytes, changeCount int64
|
||||
|
||||
err := c.db.QueryRowContext(ctx,
|
||||
`SELECT total_size_bytes, change_count FROM cache_usage WHERE id = 1`,
|
||||
).Scan(&totalBytes, &changeCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cache usage: %w", err)
|
||||
}
|
||||
|
||||
sumBytes, err := c.sumContentSizeBytes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sumBytes == totalBytes {
|
||||
return nil
|
||||
}
|
||||
|
||||
corrected, err := c.correctUsageTotal(ctx, sumBytes, changeCount)
|
||||
if err != nil || !corrected {
|
||||
return err
|
||||
}
|
||||
|
||||
c.log.Warn("corrected total cache usage to the sum of the content tables",
|
||||
"previous_usage_bytes", totalBytes, "usage_bytes", sumBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// correctUsageTotal sets the total cache usage to sumBytes, a sum of the
|
||||
// content tables taken when the change count was changeCount, and
|
||||
// reports whether it did. If a row was added, removed or resized since,
|
||||
// the count has moved and the total is left alone: the sum may have
|
||||
// missed that change, and the next pass checks again.
|
||||
func (c *Cache) correctUsageTotal(
|
||||
ctx context.Context, sumBytes, changeCount int64,
|
||||
) (bool, error) {
|
||||
result, err := c.db.ExecContext(ctx, `
|
||||
UPDATE cache_usage SET total_size_bytes = ?
|
||||
WHERE id = 1 AND change_count = ?
|
||||
`, sumBytes, changeCount)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to correct cache usage: %w", err)
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read cache usage correction: %w", err)
|
||||
}
|
||||
|
||||
return affected > 0, nil
|
||||
// allSourceContentHashes returns every tracked source content hash.
|
||||
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
||||
return queryStringColumn[ContentHash](ctx, c.db,
|
||||
`SELECT content_hash FROM source_content`,
|
||||
"source content hashes", "content hash")
|
||||
}
|
||||
|
||||
// sweepStaleTempFile removes a temp file left behind by a crashed
|
||||
|
||||
@@ -70,6 +70,7 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
||||
// backwards compatibility with existing fixtures.
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
MaxBytes: maxBytes,
|
||||
DisableDiskCache: maxBytes == 0,
|
||||
|
||||
@@ -21,12 +21,11 @@ const (
|
||||
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 stands for 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"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ import "time"
|
||||
|
||||
// CacheConfig defaults.
|
||||
const (
|
||||
DefaultCacheTTL = 24 * time.Hour
|
||||
DefaultNegativeTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: dir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -62,6 +63,7 @@ func TestNegativeCache_Expired(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: dir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// StartPendingCountWrites starts the goroutine that writes the pending
|
||||
// counts to the database whenever there are some, one UPDATE at a time:
|
||||
// however many requests pass their deadline before their counts are
|
||||
// written, at most this one write waits for the database. It is a no-op
|
||||
// when already started. The goroutine outlives the caller, so it runs with
|
||||
// its own context, which StopPendingCountWrites cancels to tell it to
|
||||
// finish.
|
||||
func (c *Cache) StartPendingCountWrites() {
|
||||
if c.pendingCountsCancel != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.pendingCountsCancel = cancel
|
||||
|
||||
go func() {
|
||||
c.pendingCountWriteLoop(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
|
||||
// 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 counts left, so that
|
||||
// they are written before the database closes. It waits for all of this at
|
||||
// most until ctx ends, then logs the counts not written and returns an
|
||||
// error.
|
||||
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
if c.pendingCountsCancel != nil {
|
||||
c.pendingCountsCancel()
|
||||
|
||||
select {
|
||||
case <-c.pendingCountsDone:
|
||||
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.
|
||||
err = fmt.Errorf("pending counts still being written: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = c.writePendingCounts(ctx)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.log.Error("counts not written at shutdown",
|
||||
"hits", c.pendingHits.Load(),
|
||||
"misses", c.pendingMisses.Load(),
|
||||
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
||||
"upstream_fetch_bytes", c.pendingUpstreamFetchBytes.Load(),
|
||||
"transforms", c.pendingTransforms.Load(),
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingCountWriteLoop is the body of the goroutine
|
||||
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
|
||||
// and returns, but does not cut off a write under way then. A write that
|
||||
// fails leaves the counts pending, for the next write.
|
||||
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
||||
defer close(c.pendingCountsDone)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.pendingCountsAdded:
|
||||
}
|
||||
|
||||
// select picks at random when ctx is cancelled and a wakeup
|
||||
// waits too.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := c.writePendingCounts(context.WithoutCancel(ctx))
|
||||
if err != nil {
|
||||
c.log.Warn("failed to write pending counts", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addPendingCount adds n to pendingCount, one of the pending counts, and
|
||||
// wakes the goroutine that writes them. pendingCountsAdded has capacity one,
|
||||
// so a wakeup already waiting is enough.
|
||||
func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
|
||||
pendingCount.Add(n)
|
||||
|
||||
select {
|
||||
case c.pendingCountsAdded <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// writePendingCounts adds the pending counts to the cache_stats row in one
|
||||
// UPDATE, then takes what it wrote out of them, leaving any added
|
||||
// meanwhile. When the UPDATE fails, they all stay pending.
|
||||
func (c *Cache) writePendingCounts(ctx context.Context) error {
|
||||
c.pendingCountsWriteMutex.Lock()
|
||||
defer c.pendingCountsWriteMutex.Unlock()
|
||||
|
||||
hits := c.pendingHits.Load()
|
||||
misses := c.pendingMisses.Load()
|
||||
upstreamFetches := c.pendingUpstreamFetches.Load()
|
||||
upstreamFetchBytes := c.pendingUpstreamFetchBytes.Load()
|
||||
transforms := c.pendingTransforms.Load()
|
||||
|
||||
if hits+misses+upstreamFetches+upstreamFetchBytes+transforms == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
UPDATE cache_stats
|
||||
SET hit_count = hit_count + ?,
|
||||
miss_count = miss_count + ?,
|
||||
upstream_fetch_count = upstream_fetch_count + ?,
|
||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||
transform_count = transform_count + ?,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`, hits, misses, upstreamFetches, upstreamFetchBytes, transforms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write pending counts: %w", err)
|
||||
}
|
||||
|
||||
c.pendingHits.Add(-hits)
|
||||
c.pendingMisses.Add(-misses)
|
||||
c.pendingUpstreamFetches.Add(-upstreamFetches)
|
||||
c.pendingUpstreamFetchBytes.Add(-upstreamFetchBytes)
|
||||
c.pendingTransforms.Add(-transforms)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pendingCountsWait is how long a test waits for the database's connection,
|
||||
// for a count to reach the database, for a write to start waiting for the
|
||||
// connection, for a request to return past its deadline, or for
|
||||
// StopPendingCountWrites to return.
|
||||
const pendingCountsWait = 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(), pendingCountsWait)
|
||||
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(pendingCountsWait)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForCounters waits until the cache_stats row holds want.
|
||||
func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(pendingCountsWait)
|
||||
|
||||
for {
|
||||
got := readCacheStatsCounters(t, cache)
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("counters = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
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, and its miss must reach the database once the connection is free.
|
||||
func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
svc.cache.StartPendingCountWrites()
|
||||
defer func() { _ = svc.cache.StopPendingCountWrites(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, 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) + pendingCountsWait):
|
||||
t.Fatal("request did not return while the database was busy")
|
||||
}
|
||||
|
||||
releaseDatabase()
|
||||
|
||||
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
|
||||
}
|
||||
|
||||
// TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting holds the
|
||||
// database's one connection while many misses are counted past their
|
||||
// deadline. At most one write may then wait for the connection, and every
|
||||
// miss must reach the database once it is free.
|
||||
func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
cache.StartPendingCountWrites()
|
||||
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
||||
|
||||
releaseDatabase := holdDatabase(t, cache)
|
||||
defer releaseDatabase()
|
||||
|
||||
waitsBefore := cache.db.Stats().WaitCount
|
||||
|
||||
// Writes given a context past its deadline fail at once.
|
||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
const misses = 20
|
||||
|
||||
for range misses {
|
||||
cache.IncrementStats(ended, false, 0)
|
||||
}
|
||||
|
||||
// Once one write waits for the connection, any others start waiting too.
|
||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||
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 at most 1", waits)
|
||||
}
|
||||
|
||||
releaseDatabase()
|
||||
|
||||
waitForCounters(t, cache, cacheStatsCounters{missCount: misses})
|
||||
}
|
||||
|
||||
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss
|
||||
// their deadline, and checks that Stats adds them to the database's counts
|
||||
// before they are written.
|
||||
func TestStats_IncludesPendingCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
_, err := cache.db.ExecContext(t.Context(),
|
||||
`UPDATE cache_stats SET hit_count = 75, miss_count = 25 WHERE id = 1`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Writes given a context past its deadline fail at once.
|
||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
cache.IncrementStats(ended, true, 0)
|
||||
cache.IncrementStats(ended, true, 0)
|
||||
cache.IncrementStats(ended, false, 0)
|
||||
|
||||
stats, err := cache.Stats(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Stats() error = %v", err)
|
||||
}
|
||||
|
||||
if stats.HitCount != 77 || stats.MissCount != 26 {
|
||||
t.Errorf("HitCount = %d, MissCount = %d, want 77 and 26",
|
||||
stats.HitCount, stats.MissCount)
|
||||
}
|
||||
|
||||
want := cacheStatsCounters{hitCount: 75, missCount: 25}
|
||||
if got := readCacheStatsCounters(t, cache); got != want {
|
||||
t.Errorf("counters in the database = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopPendingCountWrites_WritesPendingCounts holds the database's one
|
||||
// connection while the goroutine that writes the pending counts waits for it,
|
||||
// counts more, 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 counts that write left.
|
||||
func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
cache.StartPendingCountWrites()
|
||||
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
||||
|
||||
releaseDatabase := holdDatabase(t, cache)
|
||||
defer releaseDatabase()
|
||||
|
||||
waitsBefore := cache.db.Stats().WaitCount
|
||||
|
||||
// Writes given a context past its deadline fail at once.
|
||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
cache.IncrementStats(ended, true, 0)
|
||||
|
||||
// The goroutine's write waits for the connection.
|
||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||
|
||||
// Counted after that write read the pending counts
|
||||
cache.IncrementStats(ended, false, 0)
|
||||
cache.IncrementUpstreamFetch(ended, 1024)
|
||||
cache.IncrementTransformCount(ended)
|
||||
|
||||
// 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 counts above then reach the database only through the stop's own
|
||||
// write.
|
||||
cache.pendingCountsCancel()
|
||||
|
||||
stopped := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
stopped <- cache.StopPendingCountWrites(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("StopPendingCountWrites() error = %v", err)
|
||||
}
|
||||
case <-time.After(pendingCountsWait):
|
||||
t.Fatal("StopPendingCountWrites() did not return once the database was free")
|
||||
}
|
||||
|
||||
want := cacheStatsCounters{1, 1, 1, 1024, 1}
|
||||
if got := readCacheStatsCounters(t, cache); got != want {
|
||||
t.Errorf("counters = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopPendingCountWrites_ReturnsWhenItsContextEnds holds the database's
|
||||
// one connection while the goroutine that writes the pending counts 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 TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
cache.StartPendingCountWrites()
|
||||
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
||||
|
||||
releaseDatabase := holdDatabase(t, cache)
|
||||
defer releaseDatabase()
|
||||
|
||||
waitsBefore := cache.db.Stats().WaitCount
|
||||
|
||||
// Writes given a context past its deadline fail at once.
|
||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
cache.IncrementStats(ended, true, 0)
|
||||
|
||||
// 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.StopPendingCountWrites(stopCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-stopped:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("StopPendingCountWrites() error = %v, want %v",
|
||||
err, context.Canceled)
|
||||
}
|
||||
case <-time.After(pendingCountsWait):
|
||||
t.Fatal("StopPendingCountWrites() did not return once its context ended")
|
||||
}
|
||||
|
||||
releaseDatabase()
|
||||
|
||||
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
|
||||
}
|
||||
@@ -100,13 +100,6 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||
}
|
||||
|
||||
// JPEG XL is the default output format, so pixad does not start
|
||||
// without it.
|
||||
err := imageprocessor.CheckJPEGXLSupport()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||
processor := imageprocessor.New(imageprocessor.Params{
|
||||
MaxInputBytes: maxResponseSize,
|
||||
@@ -162,7 +155,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
||||
// Fall through to re-process
|
||||
} else {
|
||||
// Counted also when the request context has ended meanwhile
|
||||
s.cache.IncrementStats(ctx, true, 0)
|
||||
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
|
||||
|
||||
return &ImageResponse{
|
||||
Content: reader,
|
||||
@@ -179,7 +172,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
||||
// failed or the request context has ended meanwhile
|
||||
response, err := s.processOrWait(ctx, req)
|
||||
|
||||
s.cache.IncrementStats(ctx, false, 0)
|
||||
s.cache.IncrementStats(context.WithoutCancel(ctx), false, 0)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -300,8 +293,14 @@ func (s *Service) processOrWait(
|
||||
}
|
||||
}()
|
||||
|
||||
processingCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
processingCtx := context.WithoutCancel(ctx)
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
var cancel context.CancelFunc
|
||||
|
||||
processingCtx, cancel = context.WithDeadline(processingCtx, deadline)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
return s.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
||||
})
|
||||
@@ -338,20 +337,6 @@ func (s *Service) processOrWait(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// withoutCancelKeepingDeadline returns ctx without its cancellation but with
|
||||
// its deadline, if it has one, and the func that releases the returned
|
||||
// context.
|
||||
func withoutCancelKeepingDeadline(
|
||||
ctx context.Context,
|
||||
) (context.Context, context.CancelFunc) {
|
||||
deadline, hasDeadline := ctx.Deadline()
|
||||
if !hasDeadline {
|
||||
return context.WithoutCancel(ctx), func() {}
|
||||
}
|
||||
|
||||
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
|
||||
}
|
||||
|
||||
// loadCachedSource opens source content from cache, without reading it, and
|
||||
// returns it with its size; nil if the cached data is unavailable, empty or
|
||||
// exceeds maxResponseSize.
|
||||
@@ -461,7 +446,7 @@ func (s *Service) fetchAndProcess(
|
||||
fetchBytes := int64(len(sourceData))
|
||||
|
||||
// Counted also when the request context has ended meanwhile
|
||||
s.cache.IncrementUpstreamFetch(ctx, fetchBytes)
|
||||
s.cache.IncrementUpstreamFetch(context.WithoutCancel(ctx), fetchBytes)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||
@@ -543,7 +528,7 @@ func (s *Service) processAndStore(
|
||||
processDuration := time.Since(processStart)
|
||||
|
||||
// Counted also when the request context has ended meanwhile
|
||||
s.cache.IncrementTransformCount(ctx)
|
||||
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
|
||||
|
||||
// Read processed content
|
||||
processedData, err := io.ReadAll(processResult.Content)
|
||||
|
||||
@@ -2,9 +2,7 @@ package imgcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -432,172 +430,6 @@ func TestService_Get_Caching(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
||||
// a variant of it and sets back two days every time pixa stored for them.
|
||||
// For the source: fetched_at in source_metadata, fetched_at and
|
||||
// last_accessed_at in source_content, the fetched_at in its metadata file,
|
||||
// and the access and modification times of its file and of its metadata
|
||||
// file. For the variant: created_at and last_accessed_at in variant_content,
|
||||
// the created_at in its .meta file, and the access and modification times of
|
||||
// its file and of its .meta file. It then checks that the variant is still
|
||||
// served from the cache and that a new variant is made from the cached source
|
||||
// without fetching it again.
|
||||
//
|
||||
// Left at the present: the files' status change and birth times, which a
|
||||
// program cannot set, and the times of the directories holding the files,
|
||||
// which other entries share. The source's stored response headers are empty,
|
||||
// as the mock fetcher sends none. expires_at, etag and last_modified in
|
||||
// source_metadata and in the metadata file are never written, nor are the
|
||||
// request_cache and output_content tables.
|
||||
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
variant := photoVariant(fixtures, 85, FitCover)
|
||||
|
||||
resp, err := svc.Get(t.Context(), variant)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
twoDaysAgo := time.Now().AddDate(0, 0, -2)
|
||||
backdateSource(t, svc.cache, variant, twoDaysAgo)
|
||||
backdateVariant(t, svc.cache, variant, twoDaysAgo)
|
||||
|
||||
resp, err = svc.Get(t.Context(), variant)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
if resp.CacheStatus != CacheHit {
|
||||
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||
resp.CacheStatus, CacheHit)
|
||||
}
|
||||
|
||||
// Another quality is a new variant, made from the cached source
|
||||
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of a new variant error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||
if fetches != 1 {
|
||||
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// backdateSource sets every time pixa stored for req's cached source to when,
|
||||
// as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||
func backdateSource(
|
||||
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||
|
||||
meta, err := cache.srcMetadata.Load(req.SourceHost, pathHash)
|
||||
if err != nil {
|
||||
t.Fatalf("reading the source's metadata file: %v", err)
|
||||
}
|
||||
|
||||
meta.FetchedAt = when.Unix()
|
||||
|
||||
err = cache.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("writing the source's metadata file: %v", err)
|
||||
}
|
||||
|
||||
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
||||
|
||||
updateOneRow(t, cache, `
|
||||
UPDATE source_metadata SET fetched_at = ?
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, dbTime, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||
updateOneRow(t, cache,
|
||||
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
|
||||
dbTime, meta.ContentHash)
|
||||
setSourceLastAccessed(t, cache, ContentHash(meta.ContentHash), when)
|
||||
|
||||
for _, path := range []string{
|
||||
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
|
||||
cache.srcMetadata.metaPath(req.SourceHost, pathHash),
|
||||
} {
|
||||
err = os.Chtimes(path, when, when)
|
||||
if err != nil {
|
||||
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backdateVariant sets every time pixa stored for the cached variant req asks
|
||||
// for to when, as listed on
|
||||
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||
func backdateVariant(
|
||||
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
key := CacheKey(req)
|
||||
|
||||
content, size, contentType, err := cache.variants.LoadWithMeta(key)
|
||||
if err != nil || contentType == "" {
|
||||
t.Fatalf("reading the variant's .meta file: content type %q, error %v",
|
||||
contentType, err)
|
||||
}
|
||||
|
||||
_ = content.Close()
|
||||
|
||||
meta, err := json.Marshal(VariantMeta{
|
||||
ContentType: contentType,
|
||||
Size: size,
|
||||
CreatedAt: when.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encoding the variant's .meta file: %v", err)
|
||||
}
|
||||
|
||||
path := cache.variants.keyToPath(key)
|
||||
|
||||
err = os.WriteFile(path+variantMetaSuffix, meta, StorageFilePerm)
|
||||
if err != nil {
|
||||
t.Fatalf("writing the variant's .meta file: %v", err)
|
||||
}
|
||||
|
||||
updateOneRow(t, cache,
|
||||
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
||||
when.UTC().Format(sqliteTimestampFormat), string(key))
|
||||
setVariantLastAccessed(t, cache, key, when)
|
||||
|
||||
for _, file := range []string{path, path + variantMetaSuffix} {
|
||||
err = os.Chtimes(file, when, when)
|
||||
if err != nil {
|
||||
t.Fatalf("setting back the times of %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateOneRow runs the UPDATE statement query and fails the test unless it
|
||||
// changed exactly one row.
|
||||
func updateOneRow(t *testing.T, cache *Cache, query string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
result, err := cache.db.ExecContext(t.Context(), query, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil || rows != 1 {
|
||||
t.Fatalf("%s: rows = %d, error = %v, want 1 row", query, rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: dir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -90,6 +91,7 @@ func TestStats_ZeroCounts(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: dir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -118,6 +120,7 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||
})
|
||||
@@ -159,6 +162,7 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||
})
|
||||
@@ -228,6 +232,7 @@ func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
|
||||
|
||||
disabled, err := NewCache(enabled.db, CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
DisableDiskCache: true,
|
||||
})
|
||||
|
||||
@@ -185,6 +185,7 @@ func SetupTestService(
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
)
|
||||
|
||||
// URL parsing errors.
|
||||
@@ -22,9 +20,8 @@ var (
|
||||
ErrPathTraversal = errors.New("path traversal detected")
|
||||
)
|
||||
|
||||
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||
// processor's maximum for an output image.
|
||||
const MaxDimension = imageprocessor.MaxOutputDimension
|
||||
// MaxDimension is the maximum allowed width or height.
|
||||
const MaxDimension = 8192
|
||||
|
||||
// ValidateDimension checks one requested width or height; name ("width" or
|
||||
// "height") appears in the error. 0 means "original size" and is valid.
|
||||
@@ -41,10 +38,8 @@ func ValidateDimension(name string, value int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
|
||||
// and a size with no format, such as "800x600" or "orig"
|
||||
var sizeFormatRegex = regexp.MustCompile(
|
||||
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
|
||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
||||
|
||||
// ParsedURL contains the parsed components of an image proxy URL.
|
||||
type ParsedURL struct {
|
||||
@@ -61,13 +56,12 @@ type ParsedURL struct {
|
||||
}
|
||||
|
||||
// ParseImagePath parses the path captured by chi's wildcard:
|
||||
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL
|
||||
// <host>/<path>/<size>.<format>
|
||||
// This is the primary entry point when using chi routing.
|
||||
// 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, "/")
|
||||
@@ -78,8 +72,7 @@ func ParseImagePath(path string) (*ParsedURL, error) {
|
||||
return parseImageComponents(path)
|
||||
}
|
||||
|
||||
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>,
|
||||
// or /v1/image/<host>/<path>/<size> for JPEG XL
|
||||
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>
|
||||
// Use ParseImagePath instead when working with chi's wildcard capture.
|
||||
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||
// Remove the /v1/image/ prefix
|
||||
@@ -96,8 +89,7 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||
return parseImageComponents(remainder)
|
||||
}
|
||||
|
||||
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||
// <host>/<path>/<size> for JPEG XL.
|
||||
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||
// Check for path traversal before any other processing
|
||||
err := checkPathTraversal(remainder)
|
||||
@@ -105,7 +97,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find the last path segment, which holds "size" or "size.format"
|
||||
// Find the last path segment which contains size.format
|
||||
lastSlash := strings.LastIndex(remainder, "/")
|
||||
if lastSlash == -1 {
|
||||
return nil, ErrMissingSize
|
||||
@@ -220,7 +212,7 @@ func checkPathTraversal(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||
// parseSizeFormat parses strings like "800x600.webp" or "orig.png"
|
||||
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||
if matches == nil {
|
||||
@@ -233,11 +225,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||
)
|
||||
|
||||
if matches[4] == "orig" {
|
||||
// "orig" or "orig.format" pattern
|
||||
// "orig.format" pattern
|
||||
size = Size{Width: 0, Height: 0}
|
||||
formatStr = matches[5]
|
||||
} else {
|
||||
// "WxH" or "WxH.format" pattern
|
||||
// "WxH.format" pattern
|
||||
width, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return Size{}, "", ErrInvalidSize
|
||||
@@ -262,11 +254,6 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||
return Size{}, "", err
|
||||
}
|
||||
|
||||
// A URL that names no format is served, and signed, as JPEG XL
|
||||
if formatStr == "" {
|
||||
return size, FormatJXL, nil
|
||||
}
|
||||
|
||||
format, err := parseFormat(formatStr)
|
||||
if err != nil {
|
||||
return Size{}, "", err
|
||||
@@ -288,8 +275,6 @@ func parseFormat(s string) (ImageFormat, error) {
|
||||
return FormatWebP, nil
|
||||
case "avif":
|
||||
return FormatAVIF, nil
|
||||
case "jxl":
|
||||
return FormatJXL, nil
|
||||
case "gif":
|
||||
return FormatGIF, nil
|
||||
case "auto":
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package magic
|
||||
|
||||
import "testing"
|
||||
|
||||
// testMIMEJXL is the MIME type of JPEG XL.
|
||||
const testMIMEJXL = "image/jxl"
|
||||
|
||||
// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a
|
||||
// bare codestream and that of the container, are detected as image/jxl.
|
||||
func TestDetectFormatJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"codestream", pad(0xFF, 0x0A)},
|
||||
{"container", pad(
|
||||
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
|
||||
)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := DetectFormat(tt.data)
|
||||
if err != nil || got != MIMETypeJXL {
|
||||
t.Errorf("DetectFormat() = %q, %v, want %q", got, err, testMIMEJXL)
|
||||
}
|
||||
|
||||
err = ValidateMagicBytes(tt.data, testMIMEJXL)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJPEGXLMIMEType verifies that image/jxl is a supported input type and
|
||||
// maps to and from the format jxl.
|
||||
func TestJPEGXLMIMEType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !IsSupportedMIMEType(testMIMEJXL) {
|
||||
t.Errorf("IsSupportedMIMEType(%q) = false", testMIMEJXL)
|
||||
}
|
||||
|
||||
format, ok := MIMEToImageFormat(testMIMEJXL)
|
||||
if format != FormatJXL || !ok {
|
||||
t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true",
|
||||
testMIMEJXL, format, ok, FormatJXL)
|
||||
}
|
||||
|
||||
if got := ImageFormatToMIME(FormatJXL); got != testMIMEJXL {
|
||||
t.Errorf("ImageFormatToMIME(%q) = %q, want %q", FormatJXL, got, testMIMEJXL)
|
||||
}
|
||||
}
|
||||
+1
-19
@@ -26,7 +26,6 @@ const (
|
||||
MIMETypeWebP = MIMEType("image/webp")
|
||||
MIMETypeGIF = MIMEType("image/gif")
|
||||
MIMETypeAVIF = MIMEType("image/avif")
|
||||
MIMETypeJXL = MIMEType("image/jxl")
|
||||
MIMETypeSVG = MIMEType("image/svg+xml")
|
||||
)
|
||||
|
||||
@@ -41,7 +40,6 @@ const (
|
||||
FormatPNG ImageFormat = "png"
|
||||
FormatWebP ImageFormat = "webp"
|
||||
FormatAVIF ImageFormat = "avif"
|
||||
FormatJXL ImageFormat = "jxl"
|
||||
FormatGIF ImageFormat = "gif"
|
||||
)
|
||||
|
||||
@@ -64,11 +62,6 @@ var (
|
||||
// AVIF uses the ftyp box with brand "avif" or "avis"
|
||||
// Format: size(4 bytes) + "ftyp" + brand(4 bytes)
|
||||
magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp"
|
||||
// JPEG XL is a bare codestream or a codestream in a container
|
||||
magicJXLCodestream = []byte{0xFF, 0x0A}
|
||||
magicJXLContainer = []byte{
|
||||
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
|
||||
}
|
||||
)
|
||||
|
||||
// WebP identifier appears at offset 8 after RIFF header.
|
||||
@@ -122,12 +115,6 @@ func DetectFormat(data []byte) (MIMEType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check JPEG XL (FF0A, or the 12-byte container signature)
|
||||
if bytes.HasPrefix(data, magicJXLCodestream) ||
|
||||
bytes.HasPrefix(data, magicJXLContainer) {
|
||||
return MIMETypeJXL, nil
|
||||
}
|
||||
|
||||
// Check SVG - look for XML declaration or SVG tag
|
||||
if detectSVG(data) {
|
||||
return MIMETypeSVG, nil
|
||||
@@ -194,8 +181,7 @@ func normalizeMIMEType(mimeType string) string {
|
||||
func IsSupportedMIMEType(mimeType string) bool {
|
||||
normalized := normalizeMIMEType(mimeType)
|
||||
switch MIMEType(normalized) {
|
||||
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF,
|
||||
MIMETypeJXL, MIMETypeSVG:
|
||||
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF, MIMETypeSVG:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -240,8 +226,6 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
||||
return FormatGIF, true
|
||||
case MIMETypeAVIF:
|
||||
return FormatAVIF, true
|
||||
case MIMETypeJXL:
|
||||
return FormatJXL, true
|
||||
case MIMETypeSVG:
|
||||
// SVG has no corresponding output format.
|
||||
return "", false
|
||||
@@ -263,8 +247,6 @@ func ImageFormatToMIME(format ImageFormat) string {
|
||||
return string(MIMETypeGIF)
|
||||
case FormatAVIF:
|
||||
return string(MIMETypeAVIF)
|
||||
case FormatJXL:
|
||||
return string(MIMETypeJXL)
|
||||
case FormatOriginal:
|
||||
// Original format passes content through unchanged.
|
||||
return mimeOctetStream
|
||||
|
||||
@@ -98,10 +98,9 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
|
||||
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
||||
// IPv4 address it carries, since every such address falls in the same /64.
|
||||
// Counts are kept only for the current and the previous window, so memory
|
||||
// stays bounded. limitHandler writes the 429 after Retry-After is set; nil
|
||||
// keeps the rate limit library's plain-text "Too Many Requests".
|
||||
// stays bounded.
|
||||
func (s *Middleware) RateLimit(
|
||||
requestLimit int, window time.Duration, limitHandler http.HandlerFunc,
|
||||
requestLimit int, window time.Duration,
|
||||
) func(http.Handler) http.Handler {
|
||||
return httprate.LimitBy(requestLimit, window,
|
||||
func(r *http.Request) (string, error) {
|
||||
@@ -113,8 +112,7 @@ func (s *Middleware) RateLimit(
|
||||
}
|
||||
|
||||
return httprate.CanonicalizeIP(ip), nil
|
||||
},
|
||||
httprate.WithLimitHandler(limitHandler))
|
||||
})
|
||||
}
|
||||
|
||||
// requestIDPattern is what a request's own X-Request-Id must look like to be
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// TestImageProxyRefusesComputedSideAboveMaximum requests sizes of a 3000x600
|
||||
// and a 600x3000 PNG through both image routes. A size with one side 0, whose
|
||||
// side computed from the source's proportions would be above 8192, is refused
|
||||
// with 400 and the JSON error body. The sizes after those refusals are served
|
||||
// as before, which also shows that a refusal is not cached as a failed fetch of
|
||||
// the source: orig, a size with both sides given, and a size with one side 0
|
||||
// whose computed side stays within 8192.
|
||||
func TestImageProxyRefusesComputedSideAboveMaximum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The upstream paths of the two sources
|
||||
const (
|
||||
widePath = "/wide.png"
|
||||
tallPath = "/tall.png"
|
||||
)
|
||||
|
||||
wide := encodeTestPNG(t, 3000, 600)
|
||||
tall := encodeTestPNG(t, 600, 3000)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
|
||||
if r.URL.Path == tallPath {
|
||||
_, _ = w.Write(tall)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(wide)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
s, _, _ := startImageProxy(t, upstream)
|
||||
|
||||
generator, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
// size is the size of the /v1/image/ URL, and width and height are
|
||||
// those of the encrypted URL
|
||||
size string
|
||||
width, height int
|
||||
// wantWidth and wantHeight are the served image's, 0 for a refusal
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{widePath, "0x8192", 0, 8192, 0, 0},
|
||||
{tallPath, "8192x0", 8192, 0, 0, 0},
|
||||
{widePath, "orig", 0, 0, 3000, 600},
|
||||
{widePath, "300x200", 300, 200, 300, 200},
|
||||
{widePath, "0x300", 0, 300, 1500, 300},
|
||||
{tallPath, "300x0", 300, 0, 300, 1500},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
token, err := generator.Generate(&encurl.Payload{
|
||||
SourceHost: upstreamHost, SourcePath: tt.path,
|
||||
Width: tt.width, Height: tt.height, Format: imgcache.FormatJPEG,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encurl Generate: %v", err)
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"/v1/image/" + upstreamHost + tt.path + "/" + tt.size + ".jpeg",
|
||||
"/v1/e/" + token + "/img.jpg",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, target, nil))
|
||||
t.Logf("GET %s: %d, Content-Type %s",
|
||||
target, rec.Code, rec.Header().Get("Content-Type"))
|
||||
|
||||
if tt.wantWidth == 0 {
|
||||
checkSizeRefused(t, rec)
|
||||
} else {
|
||||
checkServedSize(t, rec, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkSizeRefused checks that rec answers 400 with the JSON error body that
|
||||
// refuses a size whose side computed from the source's proportions is above
|
||||
// 8192.
|
||||
func checkSizeRefused(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
const wantError = "size too large for the source: " +
|
||||
"the side computed from its proportions is above 8192"
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Errorf("status %d, body is not JSON: %v", rec.Code, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusBadRequest || body.Status != http.StatusBadRequest ||
|
||||
body.Error != wantError {
|
||||
t.Errorf("status %d, body %+v, want %d and the error %q",
|
||||
rec.Code, body, http.StatusBadRequest, wantError)
|
||||
}
|
||||
}
|
||||
|
||||
// checkServedSize checks that rec answers 200 with a JPEG of wantWidth by
|
||||
// wantHeight.
|
||||
func checkServedSize(
|
||||
t *testing.T, rec *httptest.ResponseRecorder, wantWidth, wantHeight int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d, want %d; body %s",
|
||||
rec.Code, http.StatusOK, rec.Body.String())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
served, err := jpeg.DecodeConfig(bytes.NewReader(rec.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Errorf("decoding the served JPEG: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if served.Width != wantWidth || served.Height != wantHeight {
|
||||
t.Errorf("served %dx%d, want %dx%d",
|
||||
served.Width, served.Height, wantWidth, wantHeight)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// TestImageProxyRefusesFitOutsideAboveMaximum requests sizes with fit=outside
|
||||
// through both image routes. fit=outside scales the source to cover the size,
|
||||
// and so past it on one side: a size for which that side would be above 8192
|
||||
// is refused with 400 and the JSON error body, whether it is the width or the
|
||||
// height, and a size for which it stays within 8192 is served at the scaled
|
||||
// size.
|
||||
func TestImageProxyRefusesFitOutsideAboveMaximum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The upstream paths of the sources
|
||||
const (
|
||||
widePath = "/wide.png"
|
||||
rowPath = "/row.png"
|
||||
columnPath = "/column.png"
|
||||
)
|
||||
|
||||
sources := map[string][]byte{
|
||||
widePath: encodeTestPNG(t, 3000, 600),
|
||||
rowPath: encodeTestPNG(t, 8192, 1),
|
||||
columnPath: encodeTestPNG(t, 1, 8192),
|
||||
}
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(sources[r.URL.Path])
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
s, _, _ := startImageProxy(t, upstream)
|
||||
|
||||
generator, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
width, height int
|
||||
// wantWidth and wantHeight are the served image's, 0 for a refusal
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{widePath, 1, 8192, 0, 0},
|
||||
{rowPath, 1, 8192, 0, 0},
|
||||
{columnPath, 8192, 1, 0, 0},
|
||||
{widePath, 1, 1200, 6000, 1200},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
token, err := generator.Generate(&encurl.Payload{
|
||||
SourceHost: upstreamHost, SourcePath: tt.path,
|
||||
Width: tt.width, Height: tt.height, Format: imgcache.FormatJPEG,
|
||||
FitMode: imgcache.FitOutside,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encurl Generate: %v", err)
|
||||
}
|
||||
|
||||
size := fmt.Sprintf("%dx%d", tt.width, tt.height)
|
||||
|
||||
for _, target := range []string{
|
||||
"/v1/image/" + upstreamHost + tt.path + "/" + size +
|
||||
".jpeg?fit=outside",
|
||||
"/v1/e/" + token + "/img.jpg",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, target, nil))
|
||||
t.Logf("GET %s: %d, Content-Type %s",
|
||||
target, rec.Code, rec.Header().Get("Content-Type"))
|
||||
|
||||
if tt.wantWidth == 0 {
|
||||
checkSizeRefused(t, rec)
|
||||
} else {
|
||||
checkServedSize(t, rec, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// requestImageFrom sends method path to s from remoteAddr and returns the
|
||||
// response. The image handlers refuse an unsigned image URL with 401 and a
|
||||
// token they cannot decrypt with 400, so either status shows the limit let
|
||||
// the request through.
|
||||
func requestImageFrom(
|
||||
t *testing.T, s *Server, method, path, remoteAddr string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
|
||||
req.RemoteAddr = remoteAddr
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestImageRequestLimitRefusesRequestOverLimit verifies that GET and HEAD on
|
||||
// both image routes count toward one limit per client, and that the request
|
||||
// after image_requests_per_minute of them is refused with 429 and a
|
||||
// Retry-After header.
|
||||
func TestImageRequestLimitRefusesRequestOverLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
||||
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
||||
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = len(requests)
|
||||
s.SetupRoutes()
|
||||
|
||||
for _, tc := range requests {
|
||||
rec := requestImageFrom(t, s, tc.method, tc.path, firstClient)
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("%s %s within the limit status = %d, want %d",
|
||||
tc.method, tc.path, rec.Code, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodHead, encryptedImagePath, firstClient)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request over the limit status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
retryAfter := rec.Header().Get("Retry-After")
|
||||
|
||||
seconds, err := strconv.Atoi(retryAfter)
|
||||
if err != nil || seconds <= 0 {
|
||||
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||
retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitAnswersWithJSONError verifies that the 429 for a
|
||||
// request over the limit carries Retry-After and the JSON error body the
|
||||
// image routes send for their other errors.
|
||||
func TestImageRequestLimitAnswersWithJSONError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = 1
|
||||
s.SetupRoutes()
|
||||
|
||||
requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request over the limit status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
retryAfter := rec.Header().Get("Retry-After")
|
||||
|
||||
seconds, err := strconv.Atoi(retryAfter)
|
||||
if err != nil || seconds <= 0 {
|
||||
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||
retryAfter)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Status int `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(rec.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("body is not JSON: %v", err)
|
||||
}
|
||||
|
||||
if body.Error == "" || body.Status != http.StatusTooManyRequests ||
|
||||
body.Timestamp == "" {
|
||||
t.Errorf("body = %+v, want an error, status %d and a timestamp",
|
||||
body, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitCountsClientsSeparately verifies that one client going
|
||||
// over the limit does not limit another.
|
||||
func TestImageRequestLimitCountsClientsSeparately(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = 1
|
||||
s.SetupRoutes()
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("first client's first request status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("first client's second request status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, secondClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("second client's first request status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitZeroLetsEveryRequestThrough verifies that with
|
||||
// image_requests_per_minute set to 0 no image request is refused with 429,
|
||||
// not even past the default limit.
|
||||
func TestImageRequestLimitZeroLetsEveryRequestThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = 0
|
||||
s.SetupRoutes()
|
||||
|
||||
for request := range config.DefaultImageRequestsPerMinute + 1 {
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("request %d status = %d, want %d",
|
||||
request+1, rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitDoesNotCountPreflight verifies that a browser's CORS
|
||||
// preflight request to an image route is answered without being counted, so
|
||||
// a client that has sent several still gets its image request through.
|
||||
func TestImageRequestLimitDoesNotCountPreflight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.AccessControlAllowOrigin = config.DefaultAccessControlAllowOrigin
|
||||
s.config.ImageRequestsPerMinute = 1
|
||||
s.SetupRoutes()
|
||||
|
||||
for range 3 {
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodOptions, unsignedImagePath, nil)
|
||||
req.RemoteAddr = firstClient
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("image request after preflights status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// TestImageProxyFlowJPEGXL requests the JPEG XL testdata/red.jxl, 64x48 and
|
||||
// made with libvips, through pixa's router, upstream fetcher, image processor
|
||||
// and disk cache: resized as PNG, resized as JPEG XL, and at its own size and
|
||||
// format, which is JPEG XL. The second request for each URL is a cache hit.
|
||||
func TestImageProxyFlowJPEGXL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source, err := os.ReadFile("testdata/red.jxl")
|
||||
if err != nil {
|
||||
t.Fatalf("reading the test image: %v", err)
|
||||
}
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jxl")
|
||||
_, _ = w.Write(source)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
s, _, _ := startImageProxy(t, upstream)
|
||||
|
||||
tests := []struct {
|
||||
sizeFormat string // the <size>.<format> part of the image URL
|
||||
contentType string
|
||||
imageType vips.ImageType
|
||||
width, height int
|
||||
}{
|
||||
{"32x24.png", "image/png", vips.ImageTypePNG, 32, 24},
|
||||
{"32x24.jxl", "image/jxl", vips.ImageTypeJXL, 32, 24},
|
||||
{"orig.orig", "image/jxl", vips.ImageTypeJXL, 64, 48},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
target := "/v1/image/" + upstreamHost + "/red.jxl/" + tt.sizeFormat
|
||||
|
||||
for _, wantCache := range []string{"MISS", "HIT"} {
|
||||
rec := getImage(t, s, target)
|
||||
gotType := rec.Header().Get("Content-Type")
|
||||
gotCache := rec.Header().Get("X-Pixa-Cache")
|
||||
|
||||
if gotType != tt.contentType || gotCache != wantCache {
|
||||
t.Errorf("%s: %s, X-Pixa-Cache %s, want %s, %s",
|
||||
target, gotType, gotCache, tt.contentType, wantCache)
|
||||
}
|
||||
|
||||
img, err := vips.NewImageFromBuffer(rec.Body.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("%s: loading the image: %v", target, err)
|
||||
}
|
||||
|
||||
if img.Format() != tt.imageType ||
|
||||
img.Width() != tt.width || img.Height() != tt.height {
|
||||
t.Errorf("%s: %s %dx%d, want %s %dx%d", target,
|
||||
vips.ImageTypes[img.Format()], img.Width(), img.Height(),
|
||||
vips.ImageTypes[tt.imageType], tt.width, tt.height)
|
||||
}
|
||||
|
||||
img.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (s *Server) SetupRoutes() {
|
||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||
r.Use(s.h.CSRF())
|
||||
r.Get("/", s.h.HandleRoot())
|
||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)).
|
||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
|
||||
Post("/", s.h.HandleRoot())
|
||||
r.Post("/generate", s.h.HandleGenerateURL())
|
||||
})
|
||||
@@ -88,18 +88,8 @@ func (s *Server) SetupRoutes() {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.refuseDuringMaintenance)
|
||||
|
||||
// Limited per client, as the login POST is, but the 429 has
|
||||
// the image routes' JSON error body; 0 turns the limit off.
|
||||
// The CORS middleware answers a preflight before it gets
|
||||
// here, so a preflight is not counted.
|
||||
if s.config.ImageRequestsPerMinute > 0 {
|
||||
r.Use(s.mw.RateLimit(s.config.ImageRequestsPerMinute,
|
||||
time.Minute, s.h.HandleTooManyRequests()))
|
||||
}
|
||||
|
||||
// Main image proxy route
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
|
||||
// format /v1/image/<host>/<path>/<width>x<height>
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||
r.Get("/image/*", s.h.HandleImage())
|
||||
r.Head("/image/*", s.h.HandleImage())
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// svgServedAsAVIF is an SVG whose first bytes pass the first-bytes check for
|
||||
// AVIF: its XML comment puts "ftypavis" at bytes 4 to 11, where an AVIF file
|
||||
// has its ftyp box and brand. libvips would load it as SVG.
|
||||
const svgServedAsAVIF = `<!--ftypavis--><svg xmlns="http://www.w3.org/2000/svg" ` +
|
||||
`width="64" height="48"><rect width="64" height="48"/></svg>`
|
||||
|
||||
// TestImageProxyRefusesSVGServedAsAVIF requests svgServedAsAVIF, which the
|
||||
// upstream answers as image/avif, through both image routes, resized as PNG
|
||||
// and in its own format (orig). Each request is answered 415, as for an SVG
|
||||
// source, never with an image.
|
||||
func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/avif")
|
||||
_, _ = io.WriteString(w, svgServedAsAVIF)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
s, _, _ := startImageProxy(t, upstream)
|
||||
|
||||
generator, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||
}
|
||||
|
||||
encryptedURL := func(format imgcache.ImageFormat) string {
|
||||
token, err := generator.Generate(&encurl.Payload{
|
||||
SourceHost: upstreamHost, SourcePath: "/drawing.avif",
|
||||
Width: 32, Height: 24, Format: format,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encurl Generate: %v", err)
|
||||
}
|
||||
|
||||
return "/v1/e/" + token + "/drawing." + string(format)
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"/v1/image/" + upstreamHost + "/drawing.avif/32x24.png",
|
||||
"/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig",
|
||||
encryptedURL(imgcache.FormatPNG),
|
||||
encryptedURL(imgcache.FormatOriginal),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, target, nil))
|
||||
t.Logf("GET %s: %d, Content-Type %s, body %q",
|
||||
target, rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusUnsupportedMediaType ||
|
||||
!strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||
t.Errorf("GET %s: %d %q, want %d unsupported source format",
|
||||
target, rec.Code, rec.Body.String(), http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProxyRefusesSVG requests an image the upstream answers as SVG,
|
||||
// in its own format (orig) and resized as PNG, through pixa's router,
|
||||
// upstream fetcher and image processor. pixa does not accept SVG as a
|
||||
// source, so each request is answered 415, never with an image.
|
||||
func TestImageProxyRefusesSVG(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg" `+
|
||||
`width="64" height="48"><rect width="64" height="48"/></svg>`)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
s, _, _ := startImageProxy(t, upstream)
|
||||
|
||||
for _, sizeFormat := range []string{"64x48.orig", "32x24.png"} {
|
||||
target := "/v1/image/" + upstreamHost + "/drawing.svg/" + sizeFormat
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, target, nil))
|
||||
t.Logf("GET %s: %d, Content-Type %s, body %s",
|
||||
target, rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("GET %s status = %d, want %d",
|
||||
target, rec.Code, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -95,12 +95,11 @@
|
||||
</label>
|
||||
<select id="format" name="format">
|
||||
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
|
||||
<option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (JPEG XL, AVIF, WebP or JPEG)</option>
|
||||
<option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (AVIF, WebP or JPEG)</option>
|
||||
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
|
||||
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
|
||||
<option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
|
||||
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option>
|
||||
<option value="jxl" {{if or (eq .FormFormat "jxl") (eq .FormFormat "")}}selected{{end}}>JPEG XL</option>
|
||||
<option value="gif" {{if eq .FormFormat "gif"}}selected{{end}}>GIF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
+5
-16
@@ -14,12 +14,11 @@
|
||||
# script/fmt-check: all the host needs, as
|
||||
# the checks compile pixa in Docker
|
||||
# script/bootstrap --cgo git, make, Go, and a C compiler and the
|
||||
# CGO image libraries (pkg-config, vips
|
||||
# with its JPEG XL support, libheif) for
|
||||
# the govips bindings instead of Node: to
|
||||
# compile pixa, in the Dockerfile's test
|
||||
# phase and build stage, which format
|
||||
# nothing
|
||||
# CGO image libraries (pkg-config, vips,
|
||||
# libheif) for the govips bindings instead
|
||||
# of Node: to compile pixa, in the
|
||||
# Dockerfile's test phase and build stage,
|
||||
# which format nothing
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -151,16 +150,6 @@ ensure_cgo_deps() {
|
||||
if ! pkg-config --exists vips; then
|
||||
pkg_install vips libvips-dev vips vips-dev
|
||||
fi
|
||||
# libvips' JPEG XL loader and saver are in the nix and brew vips
|
||||
# packages, and in apt's from Debian 12 and Ubuntu 24.04 on, but in
|
||||
# the package vips-jxl on Alpine. detect_pkgmgr is called only where
|
||||
# apk exists, as on apt it updates the package lists.
|
||||
if ! missing apk; then
|
||||
detect_pkgmgr
|
||||
fi
|
||||
if [ "$PKGMGR" = "apk" ] && ! apk info -e vips-jxl >/dev/null; then
|
||||
apk add --no-cache vips-jxl
|
||||
fi
|
||||
if ! pkg-config --exists libheif; then
|
||||
pkg_install libheif libheif-dev libheif libheif-dev
|
||||
fi
|
||||
|
||||
+1
-3
@@ -94,8 +94,7 @@ main() {
|
||||
}
|
||||
|
||||
# start_containers starts a new origin and a new pixad, and waits up to 30
|
||||
# seconds for pixad's health check to pass. pixad sees all of vegeta's
|
||||
# requests come from one address, so its image request limit is off.
|
||||
# seconds for pixad's health check to pass.
|
||||
start_containers() {
|
||||
docker run -d --name "$run-origin" \
|
||||
--network "$run" --network-alias origin \
|
||||
@@ -105,7 +104,6 @@ start_containers() {
|
||||
--network "$run" -p 127.0.0.1::8080 --health-interval=1s \
|
||||
-e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \
|
||||
-e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \
|
||||
-e PIXA_IMAGE_REQUESTS_PER_MINUTE=0 \
|
||||
"$image" >/dev/null
|
||||
|
||||
waited=0
|
||||
|
||||
Reference in New Issue
Block a user