Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c465049e67 |
+7
-9
@@ -17,8 +17,8 @@ COPY . .
|
|||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase. script/test builds it alone.
|
# Test phase. script/test builds it alone.
|
||||||
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
# golang:1.25.4-alpine, 2026-02-25
|
||||||
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
|
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ RUN go test -count=1 -timeout 90s -race -cover ./... || \
|
|||||||
# Build stage. Nothing is wanted from the two phases above: these copies
|
# 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
|
# make BuildKit build them first, so this stage runs only when lint and
|
||||||
# test passed.
|
# test passed.
|
||||||
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
# golang:1.25.4-alpine, 2026-02-25
|
||||||
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||||
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=test /src/go.sum /dev/null
|
COPY --from=test /src/go.sum /dev/null
|
||||||
@@ -77,11 +77,9 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
-o /pixad ./cmd/pixad
|
-o /pixad ./cmd/pixad
|
||||||
|
|
||||||
# Runtime stage, and the last one: a plain `docker build .` builds this
|
# 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
|
# stage and what it depends on, and nothing else.
|
||||||
# release the golang image above is based on, so that pixad runs against
|
# alpine:3.21, 2026-02-25
|
||||||
# the libvips and musl it was built with.
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
# alpine:3.22, 2026-10-08
|
|
||||||
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
|
||||||
|
|
||||||
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
||||||
# support, without which pixad does not start.
|
# support, without which pixad does not start.
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ everything in this list without further settings. The reverse proxy must:
|
|||||||
builds encrypted URLs from `Host`, and checks `Referer` against
|
builds encrypted URLs from `Host`, and checks `Referer` against
|
||||||
`referer_blocklist`;
|
`referer_blocklist`;
|
||||||
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
- 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
|
the address pixa sees the proxy's requests come from, so the login limit
|
||||||
`image_requests_per_minute` count each client by its own address (see
|
counts each client by its own address (see `trusted_proxies` under
|
||||||
`trusted_proxies` under Configuration);
|
Configuration);
|
||||||
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
||||||
longest pixa takes to fetch, convert and send an image.
|
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
|
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
||||||
allowlist, refuses upstream hosts with private or local addresses, limits login
|
allowlist, refuses upstream hosts with private or local addresses, limits login
|
||||||
attempts, each client's image requests, upstream response size and image
|
attempts, upstream response size and image dimensions, and sends the security
|
||||||
dimensions, and sends the security headers, `Strict-Transport-Security`
|
headers, `Strict-Transport-Security` included, with every response.
|
||||||
included, with every response.
|
|
||||||
|
|
||||||
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
||||||
database and the disk cache:
|
database and the disk cache:
|
||||||
@@ -89,7 +88,7 @@ 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.
|
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.
|
`docker stop` waits 10 seconds before it kills the container.
|
||||||
|
|
||||||
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
|
Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
|
||||||
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
||||||
support, which on Alpine is the `vips-jxl` package and which the nix and brew
|
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
|
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
|
||||||
@@ -176,21 +175,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
|
with the page naming a field that is not valid; 500 when the URL cannot be
|
||||||
made.
|
made.
|
||||||
- `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`.
|
- `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`.
|
||||||
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
|
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>` — an image, fetched,
|
||||||
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
|
resized and converted (below). Needs: a signature, unless the host is
|
||||||
and converted (below). Needs: a signature, unless the host is allowlisted (see
|
allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches
|
||||||
Source Hosts). Answers: 200; 304 when `If-None-Match` matches the image's
|
the image's `ETag`; 400 for a URL or parameter that is not valid, or for the
|
||||||
`ETag`; 400 for a URL or parameter that is not valid, or for the format `auto`
|
format `auto` an `Accept` header that is not valid; 406 for the format `auto`
|
||||||
an `Accept` header that is not valid; 406 for the format `auto` when `Accept`
|
when `Accept` allows none of the formats it chooses from; 401 for a missing or
|
||||||
allows none of the formats it chooses from; 401 for a missing or wrong
|
wrong signature, a missing `exp` or an `exp` in the past; 403 when the
|
||||||
signature, a missing `exp` or an `exp` in the past; 403 when the request's
|
request's `Referer` names a host in `referer_blocklist`, checked before the
|
||||||
`Referer` names a host in `referer_blocklist`, checked before the signature,
|
signature, the cache and the upstream fetch; 403 when the upstream host, or a
|
||||||
the cache and the upstream fetch; 403 when the upstream host, or a host it
|
host it redirects to, is `localhost`, ends in `.localhost` or `.local`, or has
|
||||||
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
an address in a blocked network (see `blocked_networks`); 502 when the
|
||||||
address in a blocked network (see `blocked_networks`); 429 over
|
upstream answered with an error status, and for 5 minutes after that for the
|
||||||
`image_requests_per_minute` (below); 502 when the upstream answered with an
|
same source URL; 503 when pixa is busy or in maintenance mode; 500 for any
|
||||||
error status, and for 5 minutes after that for the same source URL; 503 when
|
other failure.
|
||||||
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
|
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||||
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
||||||
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
||||||
@@ -198,7 +196,7 @@ path under `/v1/` answers 200, in maintenance mode too.
|
|||||||
expired; 504 when the upstream has not sent its response headers within
|
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
|
`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
|
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
||||||
406, 403, 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:
|
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
||||||
nothing. Answers: 200.
|
nothing. Answers: 200.
|
||||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||||
@@ -233,11 +231,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
|
MiB is refused with 413. The image routes answer the errors listed for them with
|
||||||
JSON holding `error`, `status` and `timestamp`.
|
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>.<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
|
Images are only fetched from origins using TLS with valid certificates, unless
|
||||||
@@ -248,9 +245,7 @@ A request whose query string cannot be decoded, or gives any parameter more than
|
|||||||
once, is refused with 400.
|
once, is refused with 400.
|
||||||
|
|
||||||
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
|
- `<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
|
`avif`, `jxl` (JPEG XL), `gif`, or `auto` (below)
|
||||||
second form, with no dot after the size) is served as JPEG XL, the default, as
|
|
||||||
with `jxl`
|
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
- `sig` and `exp`: the signature and its expiry, needed unless the host is
|
- `sig` and `exp`: the signature and its expiry, needed unless the host is
|
||||||
allowlisted (see Signature Specification)
|
allowlisted (see Signature Specification)
|
||||||
@@ -258,9 +253,8 @@ once, is refused with 400.
|
|||||||
both optional (values under Signature Specification). Both are part of what is
|
both optional (values under Signature Specification). Both are part of what is
|
||||||
cached, so each value of either is a separate cached image.
|
cached, so each value of either is a separate cached image.
|
||||||
|
|
||||||
The source image may be JPEG, PNG, GIF, WebP, AVIF, JPEG XL or SVG. The
|
The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
|
||||||
upstream's `Content-Type` must name its format, and the image's first bytes must
|
`Content-Type` must name its format, and the image's first bytes must match it.
|
||||||
match it.
|
|
||||||
|
|
||||||
With the format `auto`, pixa chooses the format for each request from its
|
With the format `auto`, pixa chooses the format for each request from its
|
||||||
`Accept` header, in this order:
|
`Accept` header, in this order:
|
||||||
@@ -314,14 +308,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
|
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.
|
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`, every request counts as the same client, so such a deployment
|
|
||||||
needs a higher limit, or `0`, which turns the limit off.
|
|
||||||
|
|
||||||
### Encrypted URLs
|
### Encrypted URLs
|
||||||
|
|
||||||
An encrypted URL is an image URL made on pixa's own web page by someone who
|
An encrypted URL is an image URL made on pixa's own web page by someone who
|
||||||
@@ -334,15 +320,13 @@ nor change what it asks for.
|
|||||||
lasts 30 days, or until `/logout`.
|
lasts 30 days, or until `/logout`.
|
||||||
2. On the generator page, give the source image's URL, the width and height, the
|
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
|
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
|
(`POST /generate`). Width and height both empty or `0` keep the original
|
||||||
sent with an empty format, or none, also makes a JPEG XL URL. Width and
|
size; if only one of them is empty or `0`, that side is scaled to keep the
|
||||||
height both empty or `0` keep the original size; if only one of them is empty
|
image's proportions.
|
||||||
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
|
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
||||||
it expires. `<host>` is the host the page was opened on, and the URL starts
|
it expires. `<host>` is the host the page was opened on, and the URL starts
|
||||||
with `http` instead while `debug` is on. The name after the token is ignored
|
with `http` instead while `debug` is on. The name after the token is ignored
|
||||||
and only gives the URL a file extension, `jpg` for `orig` and `auto`, and
|
and only gives the URL a file extension, `jpg` for `orig` and `auto`.
|
||||||
`jxl` for a form with no format.
|
|
||||||
|
|
||||||
The token holds the source's host, path and query and the size, format, quality,
|
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
|
fit and expiry, encrypted with a key derived from `signing_key`. The source
|
||||||
@@ -371,10 +355,10 @@ turned off.
|
|||||||
- An image with an ICC profile is converted to sRGB first, since clients show an
|
- 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
|
image with no profile as sRGB. Colours outside sRGB, such as the most
|
||||||
saturated ones in a Display P3 photo, are clipped.
|
saturated ones in a Display P3 photo, are clipped.
|
||||||
- The one exception is JPEG XL with libvips 8.16 and later: libvips then writes
|
- The one exception is JPEG XL: a JPEG XL image carries an EXIF block that
|
||||||
an EXIF block of its own into the image, as govips cannot ask libvips to leave
|
libvips writes itself, with the image's orientation, resolution, size and
|
||||||
it out. It holds the image's size and otherwise fixed values, such as an
|
colour space and fixed defaults, but nothing from the source's EXIF, as govips
|
||||||
orientation of 1 and a resolution of 72 dpi, and nothing from the source.
|
cannot ask libvips to leave it out.
|
||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
@@ -402,9 +386,8 @@ Where:
|
|||||||
- `width` — requested width in pixels, `0` for original
|
- `width` — requested width in pixels, `0` for original
|
||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format, one of those listed under Routes, with `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
|
signed as `orig` and `jpg` as `jpeg`; `auto` is signed as `auto`, not as the
|
||||||
format has the signature of the same URL ending in `.jxl`; `auto` is signed as
|
format chosen for the request
|
||||||
`auto`, not as the format chosen for the request
|
|
||||||
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when the
|
- `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
|
signature expires; a request whose `exp` is not a whole number, an empty
|
||||||
`exp=` included, is refused with 400
|
`exp=` included, is refused with 400
|
||||||
@@ -491,7 +474,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_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_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_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_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_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` |
|
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||||
@@ -541,13 +523,13 @@ Key settings in more detail:
|
|||||||
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
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
|
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
|
`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
|
(see the login limit under Routes). For a proxy on the Docker host that
|
||||||
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
|
connects to pixa over `127.0.0.1`, that address is the gateway of the
|
||||||
is the gateway of the container's Docker network (`172.17.0.1` on the default
|
container's Docker network (`172.17.0.1` on the default bridge), not the
|
||||||
bridge), not the proxy's own address; a proxy that connects through another of
|
proxy's own address; a proxy that connects through another of the host's
|
||||||
the host's addresses is seen with that address. To be sure which address it
|
addresses is seen with that address. To be sure which address it is, set this
|
||||||
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the
|
||||||
through the proxy, and read `remoteIP` in pixa's request log line for it
|
proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||||
as a duration such as `30s` (the default) or `2m`
|
as a duration such as `30s` (the default) or `2m`
|
||||||
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||||
@@ -580,11 +562,6 @@ Key settings in more detail:
|
|||||||
container's CPU limit. A request that finds all of them in use waits up to 10
|
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
|
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||||
ended first, it is answered 503 the same way
|
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`, every
|
|
||||||
request counts as the same client, so raise the limit there, or set `0`
|
|
||||||
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||||
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
`/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`)
|
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
||||||
|
|||||||
@@ -26,42 +26,10 @@ resolved. The disk cache is now size-bounded with LRU eviction
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
P2: security: per-origin rate limiting
|
P2: security: per-IP rate limiting on the image routes
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 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 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
|
- 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`
|
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
|
or the container's, is detected as `image/jxl`, which the upstream fetch
|
||||||
@@ -71,18 +39,10 @@ P2: security: per-origin rate limiting
|
|||||||
`image/jxl`. govips sends libvips a JPEG XL distance with every save, so
|
`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
|
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
|
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
|
saving, as govips cannot have libvips strip it from JPEG XL. libvips still
|
||||||
later still write an EXIF block of their own into JPEG XL, from the image's
|
writes an EXIF block of its own into JPEG XL (orientation, resolution, size,
|
||||||
size, orientation and resolution, and fixed values; the image is upright and
|
colour space, fixed defaults), with nothing from the source. libvips' default
|
||||||
is given 72 dpi before the save, so the block holds nothing from the source.
|
effort, 7, is kept. JPEG XL is not yet the default output.
|
||||||
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
|
- 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,
|
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
|
kept up to date by triggers on `source_content` and `variant_content` in the
|
||||||
@@ -744,6 +704,8 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- P2: security
|
||||||
|
- per-origin rate limiting
|
||||||
- P2: HTTP response handling
|
- P2: HTTP response handling
|
||||||
- Last-Modified headers
|
- Last-Modified headers
|
||||||
- P2: configuration
|
- P2: configuration
|
||||||
|
|||||||
@@ -115,14 +115,6 @@ upstream_connections: 64
|
|||||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||||
# max_concurrent_processing: 4
|
# 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 every request counts as the same 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)
|
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||||
upstream_fetch_timeout: 30s
|
upstream_fetch_timeout: 30s
|
||||||
|
|
||||||
|
|||||||
+10
-39
@@ -34,7 +34,6 @@ const (
|
|||||||
DefaultUpstreamFetchTimeout = 30 * time.Second
|
DefaultUpstreamFetchTimeout = 30 * time.Second
|
||||||
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
||||||
DefaultDownstreamTimeout = 60 * time.Second
|
DefaultDownstreamTimeout = 60 * time.Second
|
||||||
DefaultImageRequestsPerMinute = 600
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
@@ -62,7 +61,6 @@ const (
|
|||||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||||
keyDownstreamTimeout = "downstream_timeout"
|
keyDownstreamTimeout = "downstream_timeout"
|
||||||
keyImageRequestsPerMinute = "image_requests_per_minute"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
@@ -160,11 +158,6 @@ type Config struct {
|
|||||||
// of the per-request timeout middleware.
|
// of the per-request timeout middleware.
|
||||||
DownstreamTimeout time.Duration
|
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
|
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||||
// fetcher's dialer; the built-in ranges always apply.
|
// fetcher's dialer; the built-in ranges always apply.
|
||||||
@@ -309,8 +302,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
||||||
DownstreamTimeout: loader.durationVal(
|
DownstreamTimeout: loader.durationVal(
|
||||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
||||||
ImageRequestsPerMinute: loader.intVal(
|
|
||||||
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
|
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
BlockedNetworks: blockedNetworks,
|
BlockedNetworks: blockedNetworks,
|
||||||
TrustedProxies: trustedProxies,
|
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 default for an omitted cache_max_bytes is worked out when
|
||||||
// the cache opens; here we only record whether the operator set
|
// the cache opens; here we only record whether the operator set
|
||||||
// the key explicitly.
|
// 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
|
// 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
|
// 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,
|
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
||||||
keyImageRequestsPerMinute, "env":
|
"env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +461,6 @@ func envVarNames() map[string]string {
|
|||||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
||||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
||||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
||||||
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,14 +613,6 @@ func (c *Config) validate() error {
|
|||||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
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()
|
err = c.validateUpstreamMaxResponseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -641,9 +625,12 @@ func (c *Config) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.validateSentryDSN()
|
if c.SentryDSN != "" {
|
||||||
if err != nil {
|
parsed, err := url.Parse(c.SentryDSN)
|
||||||
return err
|
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 == "") {
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||||
@@ -655,22 +642,6 @@ func (c *Config) validate() error {
|
|||||||
return c.validateAccessControlAllowOrigin()
|
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
|
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
||||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
// 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
|
// 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_PER_HOST", "5")
|
||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||||
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
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_CACHE_MAX_BYTES", "1024")
|
||||||
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||||
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||||
@@ -100,7 +99,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
UpstreamConnectionsPerHost: 5,
|
UpstreamConnectionsPerHost: 5,
|
||||||
UpstreamConnections: 10,
|
UpstreamConnections: 10,
|
||||||
MaxConcurrentProcessing: 3,
|
MaxConcurrentProcessing: 3,
|
||||||
ImageRequestsPerMinute: 30,
|
|
||||||
CacheMaxBytes: 1024,
|
CacheMaxBytes: 1024,
|
||||||
CacheMaxBytesExplicit: true,
|
CacheMaxBytesExplicit: true,
|
||||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// Default values for optional fields.
|
// Default values for optional fields.
|
||||||
const (
|
const (
|
||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imgcache.FormatJXL
|
DefaultFormat = imgcache.FormatOriginal
|
||||||
DefaultFitMode = imgcache.FitCover
|
DefaultFitMode = imgcache.FitCover
|
||||||
|
|
||||||
// HKDF salt for URL encryption key derivation
|
// HKDF salt for URL encryption key derivation
|
||||||
@@ -37,7 +37,7 @@ type Payload struct {
|
|||||||
SourceQuery string `cbor:"q,omitempty"` // optional
|
SourceQuery string `cbor:"q,omitempty"` // optional
|
||||||
Width int `cbor:"w,omitempty"` // 0 = original
|
Width int `cbor:"w,omitempty"` // 0 = original
|
||||||
Height int `cbor:"ht,omitempty"` // 0 = original
|
Height int `cbor:"ht,omitempty"` // 0 = original
|
||||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
|
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
|
||||||
Quality int `cbor:"ql,omitempty"` // default: 85
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
||||||
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
||||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
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"
|
scheme = "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine file extension for the trailing filename. A form with no
|
// Determine file extension for the trailing filename
|
||||||
// format makes a token with none, which is served as encurl.DefaultFormat.
|
|
||||||
ext := format
|
ext := format
|
||||||
|
if ext == "" || ext == "orig" || ext == "auto" {
|
||||||
switch format {
|
ext = "jpg" // Default extension
|
||||||
case "":
|
|
||||||
ext = string(encurl.DefaultFormat)
|
|
||||||
case "orig", "auto":
|
|
||||||
ext = "jpg"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleImage handles the main image proxy route:
|
// 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>.<format>
|
||||||
// /v1/image/<host>/<path>/<width>x<height>
|
|
||||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.refuseBlockedReferer(w, r) {
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
|||||||
@@ -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,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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -256,9 +256,9 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// orig is the source's own format; encode refuses an empty format
|
// Determine output format
|
||||||
outputFormat := req.Format
|
outputFormat := req.Format
|
||||||
if outputFormat == FormatOriginal {
|
if outputFormat == FormatOriginal || outputFormat == "" {
|
||||||
outputFormat = p.formatFromString(inputFormat)
|
outputFormat = p.formatFromString(inputFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,21 +504,36 @@ func (p *ImageProcessor) encode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var params vips.ExportParams
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case FormatJPEG:
|
||||||
return exportJPEG(img, quality)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeJPEG,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatPNG:
|
case FormatPNG:
|
||||||
return exportPNG(img)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypePNG,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatGIF:
|
case FormatGIF:
|
||||||
return exportGIF(img)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeGIF,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatWebP:
|
case FormatWebP:
|
||||||
return exportWebP(img, quality)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeWEBP,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return exportAVIF(img, quality)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeAVIF,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatJXL:
|
case FormatJXL:
|
||||||
return exportJXL(img, quality)
|
return exportJXL(img, quality)
|
||||||
@@ -529,115 +544,29 @@ func (p *ImageProcessor) encode(
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
||||||
|
// GIF, which carries none of them.
|
||||||
|
params.StripMetadata = true
|
||||||
|
|
||||||
|
output, _, err := img.Export(¶ms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
|
||||||
// and without metadata. govips sends libvips a distance, the JPEG XL
|
// and without metadata. govips sends libvips a distance, the JPEG XL
|
||||||
// encoder's own measure of quality, along with the quality, and libvips then
|
// 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.
|
// uses the distance alone, so the quality is also given as a distance.
|
||||||
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
|
|
||||||
// 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
|
// govips cannot make libvips strip metadata from JPEG XL, so it is
|
||||||
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
|
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
|
||||||
// IPTC but keeps the ICC profile.
|
// IPTC but keeps the ICC profile. libvips 8.16 and later still write an
|
||||||
|
// EXIF block of their own: orientation, resolution, size, colour space
|
||||||
|
// and fixed defaults.
|
||||||
err := img.RemoveMetadata()
|
err := img.RemoveMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -648,22 +577,11 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
|||||||
return nil, err
|
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 := vips.NewJxlExportParams()
|
||||||
params.Quality = quality
|
params.Quality = quality
|
||||||
params.Distance = jxlDistance(quality)
|
params.Distance = jxlDistance(quality)
|
||||||
|
|
||||||
output, _, err := toSave.ExportJxl(params)
|
output, _, err := img.ExportJxl(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package imageprocessor
|
package imageprocessor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
@@ -50,3 +54,118 @@ func TestLibvipsSavesAndLoadsJPEGXL(t *testing.T) {
|
|||||||
t.Errorf("loaded size = %dx%d, want 64x48", loaded.Width(), loaded.Height())
|
t.Errorf("loaded size = %dx%d, want 64x48", loaded.Width(), loaded.Height())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 352 B |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 799 B |
@@ -100,8 +100,8 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
// JPEG XL is the default output format, so pixad does not start
|
// JPEG XL is to become the default output format, so pixad does not
|
||||||
// without it.
|
// start without it.
|
||||||
err := imageprocessor.CheckJPEGXLSupport()
|
err := imageprocessor.CheckJPEGXLSupport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -38,10 +38,8 @@ func ValidateDimension(name string, value int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
|
// 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+)$`)
|
||||||
var sizeFormatRegex = regexp.MustCompile(
|
|
||||||
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
|
|
||||||
|
|
||||||
// ParsedURL contains the parsed components of an image proxy URL.
|
// ParsedURL contains the parsed components of an image proxy URL.
|
||||||
type ParsedURL struct {
|
type ParsedURL struct {
|
||||||
@@ -58,13 +56,12 @@ type ParsedURL struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseImagePath parses the path captured by chi's wildcard:
|
// 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.
|
// This is the primary entry point when using chi routing.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||||
// - cdn.example.com/photos/cat.jpg/orig.png
|
// - cdn.example.com/photos/cat.jpg/orig.png
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600
|
|
||||||
func ParseImagePath(path string) (*ParsedURL, error) {
|
func ParseImagePath(path string) (*ParsedURL, error) {
|
||||||
// Strip leading slash if present (chi may include it)
|
// Strip leading slash if present (chi may include it)
|
||||||
path = strings.TrimPrefix(path, "/")
|
path = strings.TrimPrefix(path, "/")
|
||||||
@@ -75,8 +72,7 @@ func ParseImagePath(path string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(path)
|
return parseImageComponents(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>,
|
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>
|
||||||
// or /v1/image/<host>/<path>/<size> for JPEG XL
|
|
||||||
// Use ParseImagePath instead when working with chi's wildcard capture.
|
// Use ParseImagePath instead when working with chi's wildcard capture.
|
||||||
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||||
// Remove the /v1/image/ prefix
|
// Remove the /v1/image/ prefix
|
||||||
@@ -93,8 +89,7 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(remainder)
|
return parseImageComponents(remainder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
||||||
// <host>/<path>/<size> for JPEG XL.
|
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||||
// Check for path traversal before any other processing
|
// Check for path traversal before any other processing
|
||||||
err := checkPathTraversal(remainder)
|
err := checkPathTraversal(remainder)
|
||||||
@@ -102,7 +97,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
return nil, err
|
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, "/")
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
if lastSlash == -1 {
|
if lastSlash == -1 {
|
||||||
return nil, ErrMissingSize
|
return nil, ErrMissingSize
|
||||||
@@ -217,7 +212,7 @@ func checkPathTraversal(path string) error {
|
|||||||
return nil
|
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) {
|
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
@@ -230,11 +225,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if matches[4] == "orig" {
|
if matches[4] == "orig" {
|
||||||
// "orig" or "orig.format" pattern
|
// "orig.format" pattern
|
||||||
size = Size{Width: 0, Height: 0}
|
size = Size{Width: 0, Height: 0}
|
||||||
formatStr = matches[5]
|
formatStr = matches[5]
|
||||||
} else {
|
} else {
|
||||||
// "WxH" or "WxH.format" pattern
|
// "WxH.format" pattern
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.Atoi(matches[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
@@ -259,11 +254,6 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", err
|
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)
|
format, err := parseFormat(formatStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,16 +88,8 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.refuseDuringMaintenance)
|
r.Use(s.refuseDuringMaintenance)
|
||||||
|
|
||||||
// Limited per client, as the login POST is; 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
// format /v1/image/<host>/<path>/<width>x<height>
|
|
||||||
r.Get("/image/*", s.h.HandleImage())
|
r.Get("/image/*", s.h.HandleImage())
|
||||||
r.Head("/image/*", s.h.HandleImage())
|
r.Head("/image/*", s.h.HandleImage())
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</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="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
|
||||||
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</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="jxl" {{if eq .FormFormat "jxl"}}selected{{end}}>JPEG XL</option>
|
||||||
<option value="gif" {{if eq .FormFormat "gif"}}selected{{end}}>GIF</option>
|
<option value="gif" {{if eq .FormFormat "gif"}}selected{{end}}>GIF</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user