Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2005143e3d | ||
|
|
f16b6bd6a6 |
+22
-32
@@ -1,23 +1,18 @@
|
|||||||
# Lint stage
|
# Lint stage
|
||||||
# Same image as Dockerfile.lint: change both pins together.
|
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
||||||
|
|
||||||
|
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# script/bootstrap installs the build dependencies and downloads the Go
|
# Copy go mod files first for better layer caching
|
||||||
# modules. Only script/, go.mod and go.sum are copied first, so this
|
|
||||||
# layer is reused until one of them changes.
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
|
||||||
ENV container=docker
|
|
||||||
|
|
||||||
# Run formatting check and linter
|
# Run formatting check and linter
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
@@ -29,12 +24,20 @@ FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66
|
|||||||
# Depend on lint stage passing
|
# Depend on lint stage passing
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
# Install build dependencies for CGO image libraries
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
vips-dev \
|
||||||
|
libheif-dev \
|
||||||
|
pkgconfig
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Build dependencies and Go modules, as in the lint stage
|
# Copy go mod files first for better layer caching
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN GOTOOLCHAIN=auto go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -42,14 +45,8 @@ COPY . .
|
|||||||
# Run tests
|
# Run tests
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# VERSION is declared here, not earlier: a new value reruns only the
|
# Build with CGO enabled
|
||||||
# build, not script/bootstrap or the tests. CGO stays enabled for
|
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
||||||
# govips; -trimpath keeps build paths out of the binary, and -s -w
|
|
||||||
# leave out the symbol table and debug information.
|
|
||||||
ARG VERSION=dev
|
|
||||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
|
||||||
-ldflags "-s -w -X main.Version=${VERSION}" \
|
|
||||||
-o /pixad ./cmd/pixad
|
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-02-25
|
# alpine:3.21, 2026-02-25
|
||||||
@@ -60,24 +57,17 @@ RUN apk add --no-cache \
|
|||||||
vips \
|
vips \
|
||||||
libheif \
|
libheif \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata \
|
tzdata
|
||||||
su-exec
|
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /pixad /usr/local/bin/pixad
|
COPY --from=builder /pixad /usr/local/bin/pixad
|
||||||
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Create non-root user, config directory, and data directory. pixad
|
# Create non-root user, config directory, and data directory
|
||||||
# gets uid and gid 65532, which host login and system accounts do not
|
RUN adduser -D -H -s /sbin/nologin pixad && \
|
||||||
# use: a bind-mounted /var/lib/pixa is given to pixad, and on the host
|
|
||||||
# it must not belong to a person's account.
|
|
||||||
RUN addgroup -g 65532 pixad && \
|
|
||||||
adduser -D -H -s /sbin/nologin -u 65532 -G pixad pixad && \
|
|
||||||
mkdir -p /var/lib/pixa /etc/pixa && \
|
mkdir -p /var/lib/pixa /etc/pixa && \
|
||||||
chown pixad:pixad /var/lib/pixa
|
chown pixad:pixad /var/lib/pixa
|
||||||
|
|
||||||
# No USER: the entrypoint must start as root to give a bind-mounted
|
USER pixad
|
||||||
# /var/lib/pixa to pixad; it then runs the server as pixad.
|
|
||||||
WORKDIR /var/lib/pixa
|
WORKDIR /var/lib/pixa
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -90,4 +80,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|||||||
# Settings come from PORT and the PIXA_ environment variables; only
|
# Settings come from PORT and the PIXA_ environment variables; only
|
||||||
# PIXA_SIGNING_KEY is required. A config file mounted at
|
# PIXA_SIGNING_KEY is required. A config file mounted at
|
||||||
# /etc/pixa/config.yml is optional and is read when present.
|
# /etc/pixa/config.yml is optional and is read when present.
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/pixad"]
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
|
|
||||||
# which is never installed on the host. Pinned to the same image as the
|
|
||||||
# Dockerfile lint stage: change both pins together, or the two run
|
|
||||||
# different linter versions.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
|
||||||
# this image needs the same C libraries the build does. script/bootstrap
|
|
||||||
# installs them and downloads the Go modules. Only script/, go.mod and
|
|
||||||
# go.sum are copied first; they settle this layer's result, so it may
|
|
||||||
# safely be reused between runs.
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
|
||||||
ENV container=docker
|
|
||||||
|
|
||||||
# script/lint passes a different CACHEBUST on every run, and BuildKit
|
|
||||||
# keys every RUN after this ARG on its value, so the lint step always
|
|
||||||
# runs instead of returning a cached success that linted nothing.
|
|
||||||
#
|
|
||||||
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
|
|
||||||
# tmpfs that is discarded after the step. Written into the layer, they
|
|
||||||
# would pile up as build cache on every run, since no later run, with
|
|
||||||
# its new CACHEBUST, can reuse that layer.
|
|
||||||
ARG CACHEBUST
|
|
||||||
RUN --mount=type=tmpfs,target=/root/.cache script/lint
|
|
||||||
@@ -10,7 +10,7 @@ ifdef HAS_PKGCONFIG
|
|||||||
NIX_RUN_PREFIX =
|
NIX_RUN_PREFIX =
|
||||||
NIX_RUN_SUFFIX =
|
NIX_RUN_SUFFIX =
|
||||||
else
|
else
|
||||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run '
|
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run '
|
||||||
NIX_RUN_SUFFIX = '
|
NIX_RUN_SUFFIX = '
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
|||||||
@@ -34,30 +34,6 @@ else has a built-in default. A config file mounted at `/etc/pixa/config.yml`
|
|||||||
is optional: it is read when present, and an environment variable wins over
|
is optional: it is read when present, and an environment variable wins over
|
||||||
the same setting in it.
|
the same setting in it.
|
||||||
|
|
||||||
## Running under upaas
|
|
||||||
|
|
||||||
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
|
||||||
|
|
||||||
- **Port:** pixa listens on container port `8080`.
|
|
||||||
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its
|
|
||||||
database and cache. Creating the host directory when it is missing is
|
|
||||||
upaas's job, tracked in https://git.eeqj.de/sneak/upaas/issues/235.
|
|
||||||
- **Environment variables:**
|
|
||||||
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs
|
|
||||||
and login, 32+ characters, for example from
|
|
||||||
`openssl rand -base64 32`
|
|
||||||
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
|
||||||
comma-separated
|
|
||||||
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
|
||||||
default 75% of free space
|
|
||||||
- the rest are in the table under Configuration below
|
|
||||||
- **Health check:** the image's `HEALTHCHECK` requests
|
|
||||||
`/.well-known/healthcheck.json`. upaas reads the container's health 60
|
|
||||||
seconds after a deploy and marks the deploy failed unless it is
|
|
||||||
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
|
|
||||||
port changed only in a mounted config file is not seen by it: change
|
|
||||||
the port with `PORT`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Image-heavy web applications need a fast, caching reverse proxy that
|
Image-heavy web applications need a fast, caching reverse proxy that
|
||||||
@@ -81,10 +57,7 @@ prevent abuse, and allowlisted source hosts for open access.
|
|||||||
|
|
||||||
Multiple source paths may reference the same content blob; the
|
Multiple source paths may reference the same content blob; the
|
||||||
database tracks references rather than using filesystem refcounting.
|
database tracks references rather than using filesystem refcounting.
|
||||||
Toward a target of 1-5k r/s, pixa keeps in memory the content types of
|
In-process caching of request-to-output mappings targets 1-5k r/s.
|
||||||
the 10,000 transformed images most recently cached or served, so a
|
|
||||||
cache hit on one of them reads only the image file from disk and not
|
|
||||||
the metadata file stored beside it.
|
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -94,51 +67,9 @@ the metadata file stored beside it.
|
|||||||
|
|
||||||
Images are only fetched from origins using TLS with valid certificates.
|
Images are only fetched from origins using TLS with valid certificates.
|
||||||
|
|
||||||
A request whose query string cannot be decoded, or gives any parameter more
|
|
||||||
than once, is refused with 400.
|
|
||||||
|
|
||||||
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
|
|
||||||
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
|
||||||
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
|
|
||||||
`max-age` is the whole seconds left until then, at most one year, so no browser
|
|
||||||
or proxy cache keeps the image after pixa would refuse the URL. A URL with no
|
|
||||||
expiry gets one year. `immutable` only stops a client revalidating while its
|
|
||||||
copy is fresh.
|
|
||||||
|
|
||||||
The login form (`POST /`) is limited to 5 attempts per minute per client
|
|
||||||
address, counting an IPv6 client by its /64; an attempt over the limit is
|
|
||||||
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
|
||||||
address comes from `X-Forwarded-For` only when the address pixa sees for
|
|
||||||
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
|
||||||
users behind the proxy are counted as one client. That address is not always
|
|
||||||
the proxy's own: a proxy on the Docker host that connects to pixa over
|
|
||||||
`127.0.0.1` is seen as the gateway of the container's Docker network, such as
|
|
||||||
`172.17.0.1` on the default bridge, and one that connects through another of the
|
|
||||||
host's addresses is seen with that address. To be sure, read it as `remoteIP` in
|
|
||||||
pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies`
|
|
||||||
under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges),
|
|
||||||
a client with a private address can choose the address it is counted by through
|
|
||||||
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
|
|
||||||
because its own address is trusted too. Setting `trusted_proxies` to only the
|
|
||||||
address pixa sees for requests that come through the proxy closes this.
|
|
||||||
|
|
||||||
### Image Metadata
|
|
||||||
|
|
||||||
pixa decodes and re-encodes every image it serves, and removes all metadata from
|
|
||||||
the output: EXIF (GPS position, camera make, model and serial number, capture
|
|
||||||
time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be
|
|
||||||
turned off.
|
|
||||||
|
|
||||||
- The `orig` format means the source's own format, not the source's bytes: an
|
|
||||||
`orig` image is re-encoded and stripped like any other.
|
|
||||||
- An image with an EXIF orientation is turned upright first, so it displays the
|
|
||||||
same without the tag; a requested size applies to the upright image.
|
|
||||||
- An image with an ICC profile is converted to sRGB first, since clients show an
|
|
||||||
image with no profile as sRGB. Colours outside sRGB, such as the most
|
|
||||||
saturated ones in a Display P3 photo, are clipped.
|
|
||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
||||||
@@ -148,14 +79,14 @@ hosts require an HMAC-SHA256 signature.
|
|||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
||||||
prevent replay attacks. Signatures are **exact match only**: every
|
prevent replay attacks. Signatures are **exact match only**: every
|
||||||
component (host, path, query, dimensions, format, expiration, quality,
|
component (host, path, query, dimensions, format, expiration) must
|
||||||
fit) must match exactly what was signed. No suffix matching, wildcard
|
match exactly what was signed. No suffix matching, wildcard matching,
|
||||||
matching, or partial matching is supported.
|
or partial matching is supported.
|
||||||
|
|
||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
```
|
```
|
||||||
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
|
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
@@ -166,37 +97,18 @@ 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 (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when
|
- `expiration` — Unix timestamp when signature expires
|
||||||
the signature expires; a request whose `exp` is not a whole number, an
|
|
||||||
empty `exp=` included, is refused with 400
|
|
||||||
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100,
|
|
||||||
or `85` when the URL has no `q`; a request whose `q` is anything else is
|
|
||||||
refused with 400
|
|
||||||
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
|
||||||
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
|
||||||
anything else, an empty `fit=` included, is refused with 400
|
|
||||||
|
|
||||||
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
|
**Example:** resize
|
||||||
of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
||||||
compares it exactly, so a signature encoded without padding, as Node's
|
expiration 1704067200:
|
||||||
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
|
|
||||||
|
|
||||||
**Example:** with the signing key `example-signing-key-for-documentation`,
|
|
||||||
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
|
||||||
expiration 1704067200, default quality and fit:
|
|
||||||
|
|
||||||
1. Build input:
|
1. Build input:
|
||||||
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
|
||||||
2. Compute HMAC-SHA256 of it with the signing key
|
2. Compute HMAC-SHA256 with your secret key
|
||||||
3. Base64URL-encode the result, keeping the `=` padding:
|
3. Base64URL-encode the result
|
||||||
`-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=`
|
|
||||||
4. URL:
|
4. URL:
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200`
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
||||||
|
|
||||||
For the same image at quality 40 with fit `contain`, the input ends in
|
|
||||||
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`,
|
|
||||||
and the URL is
|
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain`.
|
|
||||||
|
|
||||||
**Allowlist patterns:**
|
**Allowlist patterns:**
|
||||||
|
|
||||||
@@ -209,17 +121,10 @@ and the URL is
|
|||||||
Every setting can be given as an environment variable, in a YAML config
|
Every setting can be given as an environment variable, in a YAML config
|
||||||
file (`--config`), or both. A variable present in the environment wins over
|
file (`--config`), or both. A variable present in the environment wins over
|
||||||
the file, even when it is empty, and the file wins over the built-in
|
the file, even when it is empty, and the file wins over the built-in
|
||||||
default. The one exception is a variable named in the file's `env:` section:
|
default. A variable's value is parsed as the same text in the file would
|
||||||
it is set while the file loads, so it overrides both the environment the
|
be. The three lists take comma-separated entries, with the spaces around
|
||||||
process was started with and the file's own key. A variable's value is
|
each trimmed; an empty variable is an empty list. A value that does not
|
||||||
parsed as the same text in the file would be. The three lists take
|
parse or is invalid aborts startup, naming the variable.
|
||||||
comma-separated entries, with the spaces around each trimmed; an empty
|
|
||||||
variable is an empty list. A value that does not parse or is invalid aborts
|
|
||||||
startup, naming the variable. A variable whose name starts with `PIXA_` but
|
|
||||||
is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts
|
|
||||||
startup naming it, as an unknown config key does. The one other accepted
|
|
||||||
name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The
|
|
||||||
variables set by the file's `env:` section are checked the same way.
|
|
||||||
|
|
||||||
| Variable | Config key | Meaning |
|
| Variable | Config key | Meaning |
|
||||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
@@ -233,28 +138,15 @@ variables set by the file's `env:` section are checked the same way.
|
|||||||
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
||||||
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
||||||
| `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_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
|
||||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
|
||||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
|
||||||
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
|
||||||
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
|
|
||||||
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
||||||
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
||||||
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
||||||
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
|
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
|
||||||
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Answer image requests with 503; the health check stays 200; default `false` |
|
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Maintenance flag reported by the health check; default `false` |
|
||||||
|
|
||||||
Key settings in more detail:
|
Key settings in more detail:
|
||||||
|
|
||||||
- `access_control_allow_origin` — the origin a browser lets read pixa's
|
- `access_control_allow_origin` — CORS origin
|
||||||
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
|
|
||||||
default, is any site; otherwise one `http` or `https` origin such as
|
|
||||||
`https://example.com`, whose host is a lowercase host name (letters,
|
|
||||||
digits, hyphens and dots, with a letter in its last part) or an IP address
|
|
||||||
(IPv6 in brackets, in its shortest form), with an optional port 1-65535
|
|
||||||
that has no leading zero and is not the scheme's default. Any other value,
|
|
||||||
including another scheme such as a browser extension's, aborts startup
|
|
||||||
- `allowlist_hosts` — list of allowed upstream hosts
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
||||||
added to the always-enforced built-in ranges (loopback, private,
|
added to the always-enforced built-in ranges (loopback, private,
|
||||||
@@ -265,52 +157,20 @@ Key settings in more detail:
|
|||||||
inside one of these ranges; the logged and login-recorded client
|
inside one of these ranges; the logged and login-recorded client
|
||||||
address is then the rightmost forwarded entry that is not itself a
|
address is then the rightmost forwarded entry that is not itself a
|
||||||
trusted proxy. Otherwise the direct peer address is used and the header
|
trusted proxy. Otherwise the direct peer address is used and the header
|
||||||
is ignored, so a client connecting directly from an address outside
|
is ignored, so a client connecting directly cannot spoof its address.
|
||||||
these ranges cannot spoof its address.
|
|
||||||
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
||||||
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
||||||
one, and an explicit list replaces the default. An invalid CIDR aborts
|
one, and an explicit list replaces the default. An invalid CIDR aborts
|
||||||
startup. Set this to the address pixa sees for requests that come through
|
startup. Set this to your proxy's address range if it is not already
|
||||||
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
|
covered by the defaults
|
||||||
to trust nothing else (see the login limit under Routes). For a proxy on
|
- `upstream_fetch_timeout` — timeout for origin requests
|
||||||
the Docker host that connects to pixa over `127.0.0.1`, that address is the
|
- `upstream_max_response_size` — max origin response size
|
||||||
gateway of the container's Docker network (`172.17.0.1` on the default
|
- `downstream_timeout` — client response timeout
|
||||||
bridge), not the proxy's own address; a proxy that connects through another of
|
|
||||||
the host's addresses is seen with that address. To be sure which address it
|
|
||||||
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
|
||||||
through the proxy, and read `remoteIP` in pixa's request log line for it
|
|
||||||
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream
|
|
||||||
host, as a duration such as `30s` (the default) or `2m`
|
|
||||||
- `upstream_max_response_size` — largest upstream response accepted, in
|
|
||||||
bytes; default `52428800` (50 MiB). It also limits the image data pixa
|
|
||||||
decodes
|
|
||||||
- `downstream_timeout` — time allowed for answering one client request, as a
|
|
||||||
duration; default `60s`. The upstream fetch counts toward it, and so do the
|
|
||||||
waits for an upstream connection and for a processing slot (up to 10 seconds
|
|
||||||
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
|
||||||
- `signing_key` — HMAC secret for URL signatures
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||||
disk cache entirely; omitted defaults to 75% of the free space on
|
disk cache entirely; omitted defaults to 75% of the free space on
|
||||||
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
||||||
- `upstream_connections` — the most connections to upstream hosts at once, all
|
|
||||||
hosts together, on top of `upstream_connections_per_host`; default `64`. A
|
|
||||||
fetch holds its connection until its image has been processed. A fetch that
|
|
||||||
finds all of them in use waits up to 10 seconds for one to free up; if none
|
|
||||||
does, and `downstream_timeout` has not ended first, the request is answered
|
|
||||||
503 with the error `server busy, try again later`
|
|
||||||
- `max_concurrent_processing` — the most images decoded and encoded at once;
|
|
||||||
default the number of CPUs pixa can use (`GOMAXPROCS`), which follows a
|
|
||||||
container's CPU limit. A request that finds all of them in use waits up to 10
|
|
||||||
seconds for one to free up; if none does, and `downstream_timeout` has not
|
|
||||||
ended first, it is answered 503 the same way
|
|
||||||
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
|
||||||
`/v1/e/`) answer every request with 503, a `Retry-After` header and a JSON
|
|
||||||
error body. The health check (`/.well-known/healthcheck.json`) still answers
|
|
||||||
200 and reports `"maintenance_mode": true`. It stays 200 because the image's
|
|
||||||
Docker `HEALTHCHECK` requests it: a 503 there would make the container
|
|
||||||
unhealthy, and upaas marks a deploy failed when its container is unhealthy.
|
|
||||||
The login and URL generator pages and `/metrics` keep working
|
|
||||||
|
|
||||||
See `config.example.yml` for all options with defaults.
|
See `config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
@@ -337,8 +197,7 @@ them. We provide:
|
|||||||
(bootstrap, then install-precommit)
|
(bootstrap, then install-precommit)
|
||||||
- `script/projectname` — output the project name ("pixa")
|
- `script/projectname` — output the project name ("pixa")
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/lint` — run golangci-lint, always in a container (builds
|
- `script/lint` — run golangci-lint
|
||||||
`Dockerfile.lint` when run outside one)
|
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
|
|||||||
@@ -25,169 +25,11 @@ The disk cache is now size-bounded with LRU eviction
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
P2: security: referer blacklist
|
P1: rate limit global concurrent upstream fetches to prevent resource
|
||||||
|
exhaustion
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
|
|
||||||
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
|
|
||||||
gives the directory and everything in it to `pixad` when the directory or one
|
|
||||||
of its top-level entries belongs to another user or group, sets its mode to
|
|
||||||
`750`, then runs the server as `pixad`; data left by an earlier run under
|
|
||||||
another uid is taken over this way; "Running under upaas" in `README.md` no
|
|
||||||
longer tells the operator to create or chown the host directory.
|
|
||||||
- 2026-09-29 variant content types kept in memory (closes #70):
|
|
||||||
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
|
|
||||||
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
|
|
||||||
`GetVariant` after it reads a `.meta` file, where a type `StoreVariant` added
|
|
||||||
meanwhile is kept over the one read, and never with the
|
|
||||||
`application/octet-stream` served for a variant without one; for a variant it
|
|
||||||
holds, `GetVariant` skips the `.meta` read, still opening the variant file and
|
|
||||||
taking the size from it; eviction removes the entry before deleting the files,
|
|
||||||
and `GetVariant` removes it when the file will not open; the cap is a
|
|
||||||
constant, not a setting; the unused `variantMeta` type is gone; `README.md`
|
|
||||||
describes it.
|
|
||||||
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
|
||||||
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
|
||||||
`Retry-After` header and the JSON error body, from one middleware in
|
|
||||||
`internal/server/routes.go`; the health check stays 200 and reports
|
|
||||||
`maintenance_mode`, as the image's Docker `HEALTHCHECK` requests it and upaas
|
|
||||||
marks a deploy failed when its container is unhealthy; the login and URL
|
|
||||||
generator pages and `/metrics` keep working; documented in `README.md` and
|
|
||||||
`config.example.yml`.
|
|
||||||
- 2026-09-29 bound concurrent image processing and upstream fetches (closes
|
|
||||||
#64): `max_concurrent_processing` (default the number of CPUs pixa can use)
|
|
||||||
limits the images decoded and encoded at once, and `upstream_connections`
|
|
||||||
(default 64) the connections to all upstream hosts together, on top of
|
|
||||||
`upstream_connections_per_host`; a fetch holds its connection until its image
|
|
||||||
has been processed, and a request whose source is cached reads it only once it
|
|
||||||
has a processing slot; a request that finds either limit reached waits up to
|
|
||||||
10 seconds for a free one, then gets 503 `server busy, try again later`;
|
|
||||||
libvips runs one worker thread per image with its operation cache off;
|
|
||||||
documented in `README.md` and `config.example.yml`.
|
|
||||||
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
|
|
||||||
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
|
|
||||||
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
|
|
||||||
`apk add` lines, so the build dependencies are listed in one place;
|
|
||||||
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
|
|
||||||
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
|
|
||||||
`ARG VERSION` sits just above the build, so a new version reruns neither
|
|
||||||
`script/bootstrap` nor the tests.
|
|
||||||
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
|
|
||||||
migration files moved, contents unchanged, from `internal/database/schema/`
|
|
||||||
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
|
|
||||||
`internal/db/migrations` package embeds them and `internal/database` reads
|
|
||||||
them through its `FS()`; the `internal/database` package itself stays; the
|
|
||||||
version still comes from the filename prefix, so a database that has recorded
|
|
||||||
versions 0 and 1 runs neither again.
|
|
||||||
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
|
||||||
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
|
||||||
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
|
||||||
requests that come through the proxy, which the request log shows as
|
|
||||||
`remoteIP` while it is not trusted; for a proxy on the Docker host that
|
|
||||||
connects over `127.0.0.1` that is the Docker network's gateway, not the
|
|
||||||
proxy's own address; the signature section says `sig` is base64url with the
|
|
||||||
`=` padding kept, and gives the example's `sig` for a stated signing key.
|
|
||||||
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
|
|
||||||
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
|
|
||||||
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
|
|
||||||
is not owned on the host by a person's login account; the first-run step of
|
|
||||||
"Running under upaas" in `README.md` names the uid and gid.
|
|
||||||
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
|
|
||||||
routes build `Cache-Control` from the request's `Expires`, which an encrypted
|
|
||||||
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
|
|
||||||
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
|
|
||||||
that is sooner, never negative; an allowlisted host's URL that has an `exp`
|
|
||||||
follows it too; `immutable` stays, as freshness now ends at the expiry;
|
|
||||||
documented in `README.md`.
|
|
||||||
- 2026-09-28 add the four settings `README.md` documented but pixa did not
|
|
||||||
have, which aborted startup as unknown keys (closes #61):
|
|
||||||
`access_control_allow_origin` (default `*`, the CORS origin),
|
|
||||||
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
|
|
||||||
(default 50 MiB) and `downstream_timeout` (default `60s`, both the
|
|
||||||
server's write timeout and the per-request timeout); each has a
|
|
||||||
`PIXA_` variable; durations are positive Go duration strings, the size a
|
|
||||||
whole number of bytes up to 1 GiB, the origin `*` or one `http` or
|
|
||||||
`https` origin as `README.md` describes it; an invalid value
|
|
||||||
aborts startup naming the key and the value; documented in
|
|
||||||
`config.example.yml` and `README.md`.
|
|
||||||
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
|
|
||||||
counts the cached source images and processed variants (`source_content`
|
|
||||||
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
|
|
||||||
instead of reading `request_cache` and `output_content`, which nothing
|
|
||||||
writes; those two tables are left in the schema; a disabled disk cache
|
|
||||||
reports no items and no size. A hit is counted even when the request
|
|
||||||
context has ended. A miss is counted after it is served or fails, also
|
|
||||||
when the request context has ended by then, with the bytes it read from
|
|
||||||
upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move,
|
|
||||||
including for an upstream body that fails partway or a fetched source
|
|
||||||
that then fails the magic byte check; `transform_count` counts each image
|
|
||||||
the image processor transcodes.
|
|
||||||
- 2026-09-28 strip metadata from processed images (closes #82): every output is
|
|
||||||
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
|
|
||||||
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
|
||||||
worked out) and, when it has an ICC profile, converted to sRGB; the `orig`
|
|
||||||
format is re-encoded and stripped like any other, as pixa never serves the
|
|
||||||
source bytes; there is no setting to keep metadata; documented in `README.md`.
|
|
||||||
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
|
|
||||||
attempts per minute per client address, and an attempt over the limit is
|
|
||||||
refused with 429 and a `Retry-After` header; the address is the one
|
|
||||||
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
|
|
||||||
counted by its /64, and an IPv4-mapped address as the IPv4 address it
|
|
||||||
carries; the limit is a `RateLimit` middleware in `internal/middleware` on
|
|
||||||
`github.com/go-chi/httprate`, which the image routes can reuse; the library
|
|
||||||
keeps counts for the current and the previous minute only; documented in
|
|
||||||
`README.md`.
|
|
||||||
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
|
|
||||||
cache errors (closes #72): an `exp` in the URL that is not a whole
|
|
||||||
number, an empty `exp=` included, is a 400 naming `exp` and the value,
|
|
||||||
instead of being ignored and answered with 401 as if the URL had no
|
|
||||||
`exp`; only an `exp` missing from the URL is unchanged; `README.md` says
|
|
||||||
so where it documents `exp`. A failed variant `.meta` write, source
|
|
||||||
metadata JSON write, `Stats` count query, stats counter update, negative
|
|
||||||
cache write or expired negative cache delete is now logged at `warn`
|
|
||||||
with the path or key and the error, and stays non-fatal.
|
|
||||||
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a
|
|
||||||
`fit` in the URL with an empty value (`fit=`) is a 400 naming `fit`,
|
|
||||||
instead of being served as `cover` and verified against a signature
|
|
||||||
made for `cover`; only a `fit` missing from the URL is still `cover`;
|
|
||||||
any other value still goes through the existing fit-mode check;
|
|
||||||
`README.md` says so where it documents `fit`.
|
|
||||||
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q`
|
|
||||||
that is not a whole number from 1 to 100, an empty `q` included, is a
|
|
||||||
400 naming `q` and the value, instead of being served at the default
|
|
||||||
85; the route reads `q` with the generator's quality check
|
|
||||||
(`parseFormInt` with `minQuality` and `maxQuality`); only a `q` missing
|
|
||||||
from the URL is still 85; a query string that cannot be decoded, such
|
|
||||||
as `q=80%`, is a 400 showing it; any query parameter given more than
|
|
||||||
once (`q`, `fit`, `sig`, `exp` alike) is a 400 naming it, so none is
|
|
||||||
read from its first value only; `README.md` states the range and both
|
|
||||||
query-string rules.
|
|
||||||
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes
|
|
||||||
#133): a variable whose name starts with `PIXA_` but is neither a
|
|
||||||
setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as
|
|
||||||
an unknown config key does, and `PIXA_PORT` is named with a pointer to
|
|
||||||
`PORT`; the check runs after the config file loads, so the variables
|
|
||||||
the file's `env:` section sets are checked too; documented in
|
|
||||||
`README.md`.
|
|
||||||
- 2026-09-28 start on a fresh upaas volume (closes #129): the image
|
|
||||||
starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
|
|
||||||
does not own it (`deploy/docker-entrypoint.sh`), then runs the server
|
|
||||||
as `pixad` through `su-exec`, so a root-owned host directory
|
|
||||||
bind-mounted there no longer stops the container at startup;
|
|
||||||
`README.md` gains a "Running under upaas" section.
|
|
||||||
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` +
|
|
||||||
`script/lint` (closes #104): `make lint` calls `script/lint`, the only
|
|
||||||
way the linter is run; inside a container (both Dockerfiles set
|
|
||||||
`container=docker`) it runs `golangci-lint`, anywhere else it builds the
|
|
||||||
hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again;
|
|
||||||
the `Dockerfile` lint stage runs `make lint`; no host or nix-shell
|
|
||||||
`golangci-lint` path remains (`script/bootstrap` installs no linter);
|
|
||||||
a per-run `CACHEBUST` build-arg keeps the lint step from being served
|
|
||||||
from cache, and a tmpfs mount on that step keeps Go's and
|
|
||||||
golangci-lint's caches out of its layer, so a run leaves no large build
|
|
||||||
cache behind; `golangci-lint config verify` stays out, as it fetches its
|
|
||||||
schema over an unpinned live HTTPS call
|
|
||||||
- 2026-09-28 every setting as an environment variable (closes #128, also
|
- 2026-09-28 every setting as an environment variable (closes #128, also
|
||||||
covers #99): each config key can be set by `PIXA_` plus the key in upper
|
covers #99): each config key can be set by `PIXA_` plus the key in upper
|
||||||
case (`.` written as `_`), and the port by `PORT`; a variable present in
|
case (`.` written as `_`), and the port by `PORT`; a variable present in
|
||||||
@@ -200,13 +42,6 @@ P2: security: referer blacklist
|
|||||||
the config file is looked for under `/etc/pixa` and `~/.config/pixa`
|
the config file is looked for under `/etc/pixa` and `~/.config/pixa`
|
||||||
instead of the daemon name `pixad`; documented in `README.md` and
|
instead of the daemon name `pixad`; documented in `README.md` and
|
||||||
`config.example.yml`.
|
`config.example.yml`.
|
||||||
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed
|
|
||||||
data is now `host:path:query:width:height:format:expiration:quality:fit`,
|
|
||||||
using `85` and `cover` when the URL has no `q` or `fit`, so one signed
|
|
||||||
URL can no longer be replayed across other quality and fit values to
|
|
||||||
create unauthorized cache entries and transcodes; the known-answer
|
|
||||||
vectors in `internal/signature/golden_test.go` and the README signature
|
|
||||||
specification describe the new format.
|
|
||||||
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
|
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
|
||||||
the runtime stage probing `/.well-known/healthcheck.json` with busybox
|
the runtime stage probing `/.well-known/healthcheck.json` with busybox
|
||||||
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
|
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
|
||||||
@@ -235,20 +70,6 @@ P2: security: referer blacklist
|
|||||||
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
||||||
re-resolution so the DNS-rebinding window remains closed; documented in
|
re-resolution so the DNS-rebinding window remains closed; documented in
|
||||||
`README.md` and `config.example.yml`.
|
`README.md` and `config.example.yml`.
|
||||||
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
|
|
||||||
route and the token generator (closes #62): `imgcache.ValidateDimension`
|
|
||||||
alone holds the `MaxDimension` bound and is used by the path parser, by
|
|
||||||
the new `ValidateImageRequest` (which also applies `ValidateFitMode`)
|
|
||||||
and by the generator; both the `/v1/image/` and `/v1/e/` routes call
|
|
||||||
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a
|
|
||||||
400 rather than an out-of-memory or a 500 from the processor; the URL
|
|
||||||
generator answers 400 naming the field for a `width` or `height` that is
|
|
||||||
not a number or fails the shared check, a `quality` that is not a number
|
|
||||||
from 1 to 100, a `ttl` that is not a number from 0 to the largest number
|
|
||||||
of seconds the expiry calculation can hold, or an unknown `fit`; an empty
|
|
||||||
`quality` is 85 and
|
|
||||||
an empty `ttl` never expires; the form's width and height inputs stop at
|
|
||||||
8192
|
|
||||||
- 2026-09-21 http.Server hardening (closes #92): added
|
- 2026-09-21 http.Server hardening (closes #92): added
|
||||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||||
@@ -350,8 +171,10 @@ P2: security: referer blacklist
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- P1: strip EXIF and other metadata from processed images (privacy)
|
||||||
- P2: security
|
- P2: security
|
||||||
- per-IP rate limiting on the image routes
|
- referer blacklist
|
||||||
|
- per-IP rate limiting
|
||||||
- per-origin rate limiting
|
- per-origin rate limiting
|
||||||
- P2: HTTP response handling
|
- P2: HTTP response handling
|
||||||
- Last-Modified headers
|
- Last-Modified headers
|
||||||
@@ -359,6 +182,7 @@ P2: security: referer blacklist
|
|||||||
- X-Request-ID propagation
|
- X-Request-ID propagation
|
||||||
- P2: auto format selection (format=auto based on Accept header)
|
- P2: auto format selection (format=auto based on Accept header)
|
||||||
- P2: configuration
|
- P2: configuration
|
||||||
|
- add all configuration options from README
|
||||||
- YAML config file support
|
- YAML config file support
|
||||||
- P2: operational
|
- P2: operational
|
||||||
- optional Sentry error reporting
|
- optional Sentry error reporting
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errTestHook is the error returned by the test hooks that fail.
|
|
||||||
var errTestHook = errors.New("test hook failed")
|
|
||||||
|
|
||||||
// TestRunAppExitCode checks the exit code runApp returns: the one a
|
|
||||||
// shutdown request carries, 0 for a request without one (as for SIGINT or
|
|
||||||
// SIGTERM), and 1 when the app fails to start or to stop.
|
|
||||||
func TestRunAppExitCode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
hook func(shutdowner fx.Shutdowner) fx.Hook
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "shutdown requested with exit code 1",
|
|
||||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
|
||||||
return fx.StartHook(func() error {
|
|
||||||
return shutdowner.Shutdown(fx.ExitCode(1))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
want: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "shutdown requested without an exit code",
|
|
||||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
|
||||||
return fx.StartHook(func() error {
|
|
||||||
return shutdowner.Shutdown()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
want: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "start fails",
|
|
||||||
hook: func(fx.Shutdowner) fx.Hook {
|
|
||||||
return fx.StartHook(func() error { return errTestHook })
|
|
||||||
},
|
|
||||||
want: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stop fails",
|
|
||||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
|
||||||
return fx.StartStopHook(
|
|
||||||
func() error { return shutdowner.Shutdown() },
|
|
||||||
func() error { return errTestHook },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
want: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Invoke(func(lc fx.Lifecycle, shutdowner fx.Shutdowner) {
|
|
||||||
lc.Append(tc.hook(shutdowner))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
got := runApp(app)
|
|
||||||
t.Logf("runApp() = %d", got)
|
|
||||||
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("runApp() = %d, want %d", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-57
@@ -4,24 +4,11 @@
|
|||||||
# this file: PIXA_ plus the key in upper case, with "." written as "_"
|
# this file: PIXA_ plus the key in upper case, with "." written as "_"
|
||||||
# (state_dir is PIXA_STATE_DIR, metrics.username is
|
# (state_dir is PIXA_STATE_DIR, metrics.username is
|
||||||
# PIXA_METRICS_USERNAME). The one exception is port, which is set by
|
# PIXA_METRICS_USERNAME). The one exception is port, which is set by
|
||||||
# PORT. In a variable, a list is comma-separated. A variable named in
|
# PORT. In a variable, a list is comma-separated.
|
||||||
# this file's env: section is set while the file loads, so it overrides
|
|
||||||
# both the environment the process was started with and this file's own
|
|
||||||
# key.
|
|
||||||
#
|
|
||||||
# Durations are Go duration strings such as 30s or 2m and must be
|
|
||||||
# positive; a bare number has no unit and aborts startup. Sizes are a
|
|
||||||
# whole number of bytes.
|
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
port: 8080
|
port: 8080
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
# While true, the image routes (/v1/image/ and /v1/e/) answer every request
|
|
||||||
# with 503 and a Retry-After header. The health check keeps answering 200 and
|
|
||||||
# reports maintenance_mode as true. It stays 200 because the image's Docker
|
|
||||||
# HEALTHCHECK requests it: a 503 there would make the container unhealthy, and
|
|
||||||
# upaas marks a deploy failed when its container is unhealthy.
|
|
||||||
maintenance_mode: false
|
maintenance_mode: false
|
||||||
|
|
||||||
# Data directory for SQLite database and cache files
|
# Data directory for SQLite database and cache files
|
||||||
@@ -60,13 +47,7 @@ allowlist_hosts:
|
|||||||
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
|
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
|
||||||
# a private network. An explicitly empty list ([]) trusts no one; an
|
# a private network. An explicitly empty list ([]) trusts no one; an
|
||||||
# explicit list replaces the default. An invalid CIDR aborts startup.
|
# explicit list replaces the default. An invalid CIDR aborts startup.
|
||||||
# Uncomment to override the defaults with the address pixa sees for
|
# Uncomment to override the defaults with your proxy's address range.
|
||||||
# requests that come through your proxy. That is not always the proxy's own
|
|
||||||
# address: a proxy on the Docker host that connects over 127.0.0.1 is seen
|
|
||||||
# as the gateway of the container's Docker network (172.17.0.1 on the
|
|
||||||
# default bridge), and one that connects through another host address is
|
|
||||||
# seen with that address. To be sure, look it up in the request log as the
|
|
||||||
# trusted_proxies entry in README.md describes.
|
|
||||||
# trusted_proxies:
|
# trusted_proxies:
|
||||||
# - 10.0.0.0/8
|
# - 10.0.0.0/8
|
||||||
# - 2001:db8::/32
|
# - 2001:db8::/32
|
||||||
@@ -77,42 +58,6 @@ allow_http: false
|
|||||||
# Maximum concurrent connections per upstream host (default: 20)
|
# Maximum concurrent connections per upstream host (default: 20)
|
||||||
upstream_connections_per_host: 20
|
upstream_connections_per_host: 20
|
||||||
|
|
||||||
# Maximum concurrent connections to all upstream hosts together, on top of
|
|
||||||
# the per-host limit (default: 64). A fetch holds its connection until its
|
|
||||||
# image has been processed. A fetch that finds none free waits up to 10
|
|
||||||
# seconds for one, and if none frees up the request is answered 503, unless
|
|
||||||
# downstream_timeout has ended first.
|
|
||||||
upstream_connections: 64
|
|
||||||
|
|
||||||
# Maximum number of images decoded and encoded at once (default: the
|
|
||||||
# number of CPUs pixa can use, which follows a container's CPU limit). A
|
|
||||||
# request that finds none free waits up to 10 seconds for one, and if none
|
|
||||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
|
||||||
# max_concurrent_processing: 4
|
|
||||||
|
|
||||||
# Time allowed for one fetch from an upstream host (default: 30s)
|
|
||||||
upstream_fetch_timeout: 30s
|
|
||||||
|
|
||||||
# Largest upstream response accepted, in bytes, at most 1073741824
|
|
||||||
# (1 GiB) (default: 52428800, 50 MiB)
|
|
||||||
upstream_max_response_size: 52428800
|
|
||||||
|
|
||||||
# Time allowed for answering one client request (default: 60s). The
|
|
||||||
# upstream fetch counts toward it, and so do the waits for an upstream
|
|
||||||
# connection and for a processing slot (up to 10 seconds each), so keep it
|
|
||||||
# longer than upstream_fetch_timeout plus 20 seconds.
|
|
||||||
downstream_timeout: 60s
|
|
||||||
|
|
||||||
# The origin a browser lets read pixa's responses, sent as the CORS
|
|
||||||
# Access-Control-Allow-Origin header: "*" (the default) is any site;
|
|
||||||
# otherwise one http or https origin such as https://example.com, whose
|
|
||||||
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
|
||||||
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
|
||||||
# shortest form), with an optional port 1-65535 that has no leading zero
|
|
||||||
# and is not the scheme's default. Any other value, including another
|
|
||||||
# scheme such as a browser extension's, aborts startup.
|
|
||||||
access_control_allow_origin: "*"
|
|
||||||
|
|
||||||
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
||||||
# given; 0 disables the disk cache entirely (every request fetches and
|
# given; 0 disables the disk cache entirely (every request fetches and
|
||||||
# processes uncached). When omitted, the default is 75% of the free
|
# processes uncached). When omitted, the default is 75% of the free
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
|
|
||||||
# root only to make /var/lib/pixa usable by pixad: a host directory
|
|
||||||
# bind-mounted there keeps its host owner, often root, and data from an
|
|
||||||
# earlier run may belong to another uid. The server itself always runs
|
|
||||||
# as pixad.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
main() {
|
|
||||||
mkdir -p /var/lib/pixa
|
|
||||||
# Only the directory and its top-level entries are checked, so a
|
|
||||||
# normal start does not walk the cache. -depth gives each directory
|
|
||||||
# to pixad after its contents, so a start stopped part way leaves
|
|
||||||
# something at the top for the next start to find; -h changes a
|
|
||||||
# symlink itself, never the file it points to.
|
|
||||||
if [ -n "$(find /var/lib/pixa -maxdepth 1 \( ! -user pixad -o ! -group pixad \))" ]; then
|
|
||||||
find /var/lib/pixa -depth -exec chown -h pixad:pixad {} +
|
|
||||||
fi
|
|
||||||
chmod 750 /var/lib/pixa
|
|
||||||
exec su-exec pixad /usr/local/bin/pixad "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -11,10 +11,8 @@ require (
|
|||||||
github.com/getsentry/sentry-go v0.40.0
|
github.com/getsentry/sentry-go v0.40.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/httprate v0.16.0
|
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
@@ -93,7 +91,6 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
@@ -116,7 +113,6 @@ require (
|
|||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
||||||
|
|||||||
@@ -114,8 +114,6 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
|||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
|
|
||||||
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
|
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
@@ -228,8 +226,6 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
|||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
@@ -255,8 +251,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -402,10 +396,6 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
|||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
|
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The variables that set the two concurrency limits.
|
|
||||||
const (
|
|
||||||
testMaxConcurrentProcessingVar = "PIXA_MAX_CONCURRENT_PROCESSING"
|
|
||||||
testUpstreamConnectionsVar = "PIXA_UPSTREAM_CONNECTIONS"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
|
||||||
// max_concurrent_processing is the number of CPUs Go uses and an omitted
|
|
||||||
// upstream_connections is 64.
|
|
||||||
func TestOmittedConcurrencyLimitsUseDefaults(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.MaxConcurrentProcessing != runtime.GOMAXPROCS(0) {
|
|
||||||
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
|
|
||||||
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamConnections != 64 {
|
|
||||||
t.Errorf("UpstreamConnections = %d, want 64", c.UpstreamConnections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
|
||||||
// two limits are used as given.
|
|
||||||
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine+
|
|
||||||
"max_concurrent_processing: 3\nupstream_connections: 10\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("valid config should load, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.MaxConcurrentProcessing != 3 {
|
|
||||||
t.Errorf("MaxConcurrentProcessing = %d, want 3", c.MaxConcurrentProcessing)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamConnections != 10 {
|
|
||||||
t.Errorf("UpstreamConnections = %d, want 10", c.UpstreamConnections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidConcurrencyLimitAbortsStartup checks that a limit that is
|
|
||||||
// not a whole number of at least 1, or is null, aborts startup naming the
|
|
||||||
// key and the value, and the variable too where the value could have come
|
|
||||||
// from it.
|
|
||||||
func TestInvalidConcurrencyLimitAbortsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
processing := keyMaxConcurrentProcessing
|
|
||||||
connections := keyUpstreamConnections
|
|
||||||
|
|
||||||
runAbortCases(t, []abortCase{
|
|
||||||
{
|
|
||||||
name: "max_concurrent_processing zero",
|
|
||||||
yaml: signingKeyLine + processing + ": 0\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
processing, testMaxConcurrentProcessingVar, "value 0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "max_concurrent_processing negative",
|
|
||||||
yaml: signingKeyLine + processing + ": -2\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
processing, testMaxConcurrentProcessingVar, "value -2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "max_concurrent_processing not a number",
|
|
||||||
yaml: signingKeyLine + processing + ": lots\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
processing, testMaxConcurrentProcessingVar, "lots",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "max_concurrent_processing fractional",
|
|
||||||
yaml: signingKeyLine + processing + ": 1.5\n",
|
|
||||||
wantErrSubstrings: []string{processing, "1.5"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "max_concurrent_processing null",
|
|
||||||
yaml: signingKeyLine + processing + ": null\n",
|
|
||||||
wantErrSubstrings: []string{processing, nullValueText},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_connections zero",
|
|
||||||
yaml: signingKeyLine + connections + ": 0\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
connections, testUpstreamConnectionsVar, "value 0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_connections negative",
|
|
||||||
yaml: signingKeyLine + connections + ": -5\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
connections, testUpstreamConnectionsVar, "value -5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_connections not a number",
|
|
||||||
yaml: signingKeyLine + connections + ": many\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
connections, testUpstreamConnectionsVar, "many",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_connections null",
|
|
||||||
yaml: signingKeyLine + connections + ": null\n",
|
|
||||||
wantErrSubstrings: []string{connections, nullValueText},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrencyLimitsFromEnvironment checks that the two variables set
|
|
||||||
// the limits over the config file, and that an invalid value in either
|
|
||||||
// aborts startup naming the variable and the value.
|
|
||||||
func TestConcurrencyLimitsFromEnvironment(t *testing.T) {
|
|
||||||
t.Setenv(testMaxConcurrentProcessingVar, "3")
|
|
||||||
t.Setenv(testUpstreamConnectionsVar, "10")
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine+
|
|
||||||
"max_concurrent_processing: 5\nupstream_connections: 50\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("limits from the environment should load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.MaxConcurrentProcessing != 3 || c.UpstreamConnections != 10 {
|
|
||||||
t.Errorf("limits = %d and %d, want 3 and 10 from the environment",
|
|
||||||
c.MaxConcurrentProcessing, c.UpstreamConnections)
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
variable string
|
|
||||||
value string
|
|
||||||
}{
|
|
||||||
{testMaxConcurrentProcessingVar, "lots"},
|
|
||||||
{testMaxConcurrentProcessingVar, "0"},
|
|
||||||
{testUpstreamConnectionsVar, "-1"},
|
|
||||||
{testUpstreamConnectionsVar, "ten"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.variable+"="+tc.value, func(t *testing.T) {
|
|
||||||
t.Setenv(tc.variable, tc.value)
|
|
||||||
|
|
||||||
_, err := configFromYAML(t, signingKeyLine)
|
|
||||||
wantStartupError(t, err, tc.variable, tc.value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-278
@@ -10,11 +10,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -26,11 +24,6 @@ const (
|
|||||||
DefaultPort = 8080
|
DefaultPort = 8080
|
||||||
DefaultStateDir = "/var/lib/pixa"
|
DefaultStateDir = "/var/lib/pixa"
|
||||||
DefaultUpstreamConnectionsPerHost = 20
|
DefaultUpstreamConnectionsPerHost = 20
|
||||||
DefaultUpstreamConnections = 64
|
|
||||||
DefaultAccessControlAllowOrigin = "*"
|
|
||||||
DefaultUpstreamFetchTimeout = 30 * time.Second
|
|
||||||
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
|
||||||
DefaultDownstreamTimeout = 60 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
@@ -48,15 +41,9 @@ const (
|
|||||||
keyAllowlistHosts = "allowlist_hosts"
|
keyAllowlistHosts = "allowlist_hosts"
|
||||||
keyAllowHTTP = "allow_http"
|
keyAllowHTTP = "allow_http"
|
||||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
keyUpstreamConnections = "upstream_connections"
|
|
||||||
keyMaxConcurrentProcessing = "max_concurrent_processing"
|
|
||||||
keyCacheMaxBytes = "cache_max_bytes"
|
keyCacheMaxBytes = "cache_max_bytes"
|
||||||
keyBlockedNetworks = "blocked_networks"
|
keyBlockedNetworks = "blocked_networks"
|
||||||
keyTrustedProxies = "trusted_proxies"
|
keyTrustedProxies = "trusted_proxies"
|
||||||
keyAccessControlAllowOrigin = "access_control_allow_origin"
|
|
||||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
|
||||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
|
||||||
keyDownstreamTimeout = "downstream_timeout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
@@ -71,7 +58,6 @@ var (
|
|||||||
errValueRequired = errors.New("a value is required")
|
errValueRequired = errors.New("a value is required")
|
||||||
errValueEmpty = errors.New("value must not be empty")
|
errValueEmpty = errors.New("value must not be empty")
|
||||||
errUnknownConfigKeys = errors.New("unknown config keys")
|
errUnknownConfigKeys = errors.New("unknown config keys")
|
||||||
errUnknownEnvVars = errors.New("unknown environment variables")
|
|
||||||
errNotAString = errors.New("not a string")
|
errNotAString = errors.New("not a string")
|
||||||
errNotAnInteger = errors.New("not an integer")
|
errNotAnInteger = errors.New("not an integer")
|
||||||
errNotABoolean = errors.New("not a boolean")
|
errNotABoolean = errors.New("not a boolean")
|
||||||
@@ -82,8 +68,7 @@ var (
|
|||||||
errEmptyEntry = errors.New("contains an empty entry")
|
errEmptyEntry = errors.New("contains an empty entry")
|
||||||
errNotAValidURL = errors.New("not a valid URL")
|
errNotAValidURL = errors.New("not a valid URL")
|
||||||
errPortOutOfRange = errors.New("outside the valid port range")
|
errPortOutOfRange = errors.New("outside the valid port range")
|
||||||
errSizeOutOfRange = errors.New("outside the accepted range")
|
errTooFewConnections = errors.New("must be at least 1")
|
||||||
errMustBeAtLeastOne = errors.New("must be at least 1")
|
|
||||||
errValueTooShort = errors.New("value too short")
|
errValueTooShort = errors.New("value too short")
|
||||||
errPlaceholderKey = errors.New(
|
errPlaceholderKey = errors.New(
|
||||||
"is the placeholder from config.example.yml; " +
|
"is the placeholder from config.example.yml; " +
|
||||||
@@ -100,10 +85,6 @@ var (
|
|||||||
errNotBareHostname = errors.New(
|
errNotBareHostname = errors.New(
|
||||||
"must be a bare hostname without scheme, path, or whitespace")
|
"must be a bare hostname without scheme, path, or whitespace")
|
||||||
errNoHostnameLabels = errors.New("contains no hostname labels")
|
errNoHostnameLabels = errors.New("contains no hostname labels")
|
||||||
errNotADuration = errors.New("not a duration such as 30s or 2m")
|
|
||||||
errMustBePositive = errors.New("must be positive")
|
|
||||||
errNotAnOrigin = errors.New(
|
|
||||||
`not "*" or an origin such as https://example.com`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines dependencies for Config.
|
// Params defines dependencies for Config.
|
||||||
@@ -130,29 +111,6 @@ type Config struct {
|
|||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
|
|
||||||
// UpstreamConnections is the most concurrent connections to all
|
|
||||||
// upstream hosts together, on top of the per-host limit.
|
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
|
||||||
UpstreamConnections int
|
|
||||||
MaxConcurrentProcessing int
|
|
||||||
|
|
||||||
// UpstreamFetchTimeout is the time allowed for one fetch from an
|
|
||||||
// upstream host. UpstreamMaxResponseSize is the largest upstream
|
|
||||||
// response accepted, in bytes, and also the image processor's input
|
|
||||||
// limit.
|
|
||||||
UpstreamFetchTimeout time.Duration
|
|
||||||
UpstreamMaxResponseSize int64
|
|
||||||
|
|
||||||
// AccessControlAllowOrigin is the origin the CORS middleware allows
|
|
||||||
// to read responses: "*" for any, or one origin such as
|
|
||||||
// https://example.com.
|
|
||||||
AccessControlAllowOrigin string
|
|
||||||
|
|
||||||
// DownstreamTimeout bounds how long answering one client request may
|
|
||||||
// take. It is both the HTTP server's write timeout and the deadline
|
|
||||||
// of the per-request timeout middleware.
|
|
||||||
DownstreamTimeout time.Duration
|
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -196,13 +154,6 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading the config file sets the variables in its env section,
|
|
||||||
// so this also checks their names.
|
|
||||||
err = validateKnownEnvVars()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
log.Info("no config file found, using environment variables and defaults")
|
log.Info("no config file found, using environment variables and defaults")
|
||||||
}
|
}
|
||||||
@@ -236,12 +187,14 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
// unparseable or invalid is an error: defaults apply only to omitted
|
// unparseable or invalid is an error: defaults apply only to omitted
|
||||||
// keys, never to invalid explicit values.
|
// keys, never to invalid explicit values.
|
||||||
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||||
err := validateKnownKeys(sc)
|
if sc != nil {
|
||||||
if err != nil {
|
err := validateKnownKeys(sc)
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateAllowlistHostsValue(sc)
|
err := validateAllowlistHostsValue(sc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -279,20 +232,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||||
UpstreamConnectionsPerHost: loader.intVal(
|
UpstreamConnectionsPerHost: loader.intVal(
|
||||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
UpstreamConnections: loader.intVal(
|
|
||||||
keyUpstreamConnections, DefaultUpstreamConnections),
|
|
||||||
// Decoding and encoding are CPU-bound, so the default is one image
|
|
||||||
// per CPU Go uses, which follows a container's CPU limit.
|
|
||||||
MaxConcurrentProcessing: loader.intVal(
|
|
||||||
keyMaxConcurrentProcessing, runtime.GOMAXPROCS(0)),
|
|
||||||
UpstreamFetchTimeout: loader.durationVal(
|
|
||||||
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
|
||||||
UpstreamMaxResponseSize: loader.int64Val(
|
|
||||||
keyUpstreamMaxResponseSize, DefaultUpstreamMaxResponseSize),
|
|
||||||
AccessControlAllowOrigin: loader.stringVal(
|
|
||||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
|
||||||
DownstreamTimeout: loader.durationVal(
|
|
||||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
BlockedNetworks: blockedNetworks,
|
BlockedNetworks: blockedNetworks,
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
@@ -335,13 +274,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
// being silently ignored, and rejects keys that are explicitly set to
|
// being silently ignored, and rejects keys that are explicitly set to
|
||||||
// null: a null is a SET value, never an omission, so it must not
|
// null: a null is a SET value, never an omission, so it must not
|
||||||
// silently take the default. The env section is permitted because
|
// silently take the default. The env section is permitted because
|
||||||
// smartconfig consumes it for environment variable injection. A nil sc
|
// smartconfig consumes it for environment variable injection.
|
||||||
// means no config file, which has no keys to check.
|
|
||||||
func validateKnownKeys(sc *smartconfig.Config) error {
|
func validateKnownKeys(sc *smartconfig.Config) error {
|
||||||
if sc == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var unknown, nullKeys []string
|
var unknown, nullKeys []string
|
||||||
|
|
||||||
for key, value := range sc.Data() {
|
for key, value := range sc.Data() {
|
||||||
@@ -411,10 +345,8 @@ func isKnownConfigKey(key string) bool {
|
|||||||
switch key {
|
switch key {
|
||||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||||
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
|
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
keyTrustedProxies, "env":
|
||||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
|
||||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,55 +371,12 @@ func envVarNames() map[string]string {
|
|||||||
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
|
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
|
||||||
keyAllowHTTP: "PIXA_ALLOW_HTTP",
|
keyAllowHTTP: "PIXA_ALLOW_HTTP",
|
||||||
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
|
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
|
||||||
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
|
|
||||||
keyMaxConcurrentProcessing: "PIXA_MAX_CONCURRENT_PROCESSING",
|
|
||||||
keyCacheMaxBytes: "PIXA_CACHE_MAX_BYTES",
|
keyCacheMaxBytes: "PIXA_CACHE_MAX_BYTES",
|
||||||
keyBlockedNetworks: "PIXA_BLOCKED_NETWORKS",
|
keyBlockedNetworks: "PIXA_BLOCKED_NETWORKS",
|
||||||
keyTrustedProxies: "PIXA_TRUSTED_PROXIES",
|
keyTrustedProxies: "PIXA_TRUSTED_PROXIES",
|
||||||
keyAccessControlAllowOrigin: "PIXA_ACCESS_CONTROL_ALLOW_ORIGIN",
|
|
||||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
|
||||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
|
||||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateKnownEnvVars rejects environment variables whose names start
|
|
||||||
// with PIXA_ but that are neither a setting's variable nor
|
|
||||||
// PIXA_CONFIG_PATH, so a misspelled variable fails at startup instead of
|
|
||||||
// being silently ignored, as validateKnownKeys does for config file keys.
|
|
||||||
// New calls it after loading the config file, so the variables the
|
|
||||||
// file's env section sets are checked too.
|
|
||||||
func validateKnownEnvVars() error {
|
|
||||||
known := map[string]bool{"PIXA_CONFIG_PATH": true}
|
|
||||||
|
|
||||||
for _, name := range envVarNames() {
|
|
||||||
known[name] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var unknown []string
|
|
||||||
|
|
||||||
for _, entry := range os.Environ() {
|
|
||||||
name, _, _ := strings.Cut(entry, "=")
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case !strings.HasPrefix(name, "PIXA_") || known[name]:
|
|
||||||
continue
|
|
||||||
case name == "PIXA_PORT":
|
|
||||||
unknown = append(unknown, name+" (use PORT for the port)")
|
|
||||||
default:
|
|
||||||
unknown = append(unknown, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(unknown) > 0 {
|
|
||||||
sort.Strings(unknown)
|
|
||||||
|
|
||||||
return fmt.Errorf("%w: %s", errUnknownEnvVars, strings.Join(unknown, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookupValue returns the value set for key and whether one is set. The
|
// lookupValue returns the value set for key and whether one is set. The
|
||||||
// key's environment variable wins when it is present, even when empty;
|
// key's environment variable wins when it is present, even when empty;
|
||||||
// its value is a string, read exactly as the same text quoted in the
|
// its value is a string, read exactly as the same text quoted in the
|
||||||
@@ -584,9 +473,10 @@ func (c *Config) validate() error {
|
|||||||
settingName(keyPort), c.Port, errPortOutOfRange, maxPort)
|
settingName(keyPort), c.Port, errPortOutOfRange, maxPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.validateConcurrencyLimits()
|
if c.UpstreamConnectionsPerHost < 1 {
|
||||||
if err != nil {
|
return fmt.Errorf("%s: value %d %w",
|
||||||
return err
|
settingName(keyUpstreamConnectionsPerHost),
|
||||||
|
c.UpstreamConnectionsPerHost, errTooFewConnections)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.StateDir == "" {
|
if c.StateDir == "" {
|
||||||
@@ -600,11 +490,6 @@ func (c *Config) validate() error {
|
|||||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.validateUpstreamMaxResponseSize()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range c.AllowlistHosts {
|
for _, host := range c.AllowlistHosts {
|
||||||
err := validateAllowlistHost(host)
|
err := validateAllowlistHost(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -626,106 +511,6 @@ func (c *Config) validate() error {
|
|||||||
errMustBeSetTogether)
|
errMustBeSetTogether)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.validateAccessControlAllowOrigin()
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
|
||||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
|
||||||
// memory, and the image processor reads one byte past this limit, which
|
|
||||||
// must not overflow.
|
|
||||||
func (c *Config) validateUpstreamMaxResponseSize() error {
|
|
||||||
const maxUpstreamMaxResponseSize = 1 << 30 // 1 GiB
|
|
||||||
if c.UpstreamMaxResponseSize < 1 ||
|
|
||||||
c.UpstreamMaxResponseSize > maxUpstreamMaxResponseSize {
|
|
||||||
return fmt.Errorf("%s: value %d is %w 1-%d",
|
|
||||||
settingName(keyUpstreamMaxResponseSize), c.UpstreamMaxResponseSize,
|
|
||||||
errSizeOutOfRange, maxUpstreamMaxResponseSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateAccessControlAllowOrigin accepts "*" or an origin exactly as a browser
|
|
||||||
// sends it: http or https, an IP address as netip writes it or a lowercase name
|
|
||||||
// with a letter in its last part, and an optional port 1-65535, not the default.
|
|
||||||
func (c *Config) validateAccessControlAllowOrigin() error {
|
|
||||||
origin := c.AccessControlAllowOrigin
|
|
||||||
if origin == "*" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
errOrigin := fmt.Errorf("%s: value %q is %w",
|
|
||||||
settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin)
|
|
||||||
|
|
||||||
parsed, err := url.Parse(origin)
|
|
||||||
if err != nil {
|
|
||||||
return errOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultPort := map[string]string{"http": "80", "https": "443"}[parsed.Scheme]
|
|
||||||
if defaultPort == "" {
|
|
||||||
return errOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
const letters = "abcdefghijklmnopqrstuvwxyz"
|
|
||||||
|
|
||||||
host := parsed.Hostname()
|
|
||||||
lastPart := host[strings.LastIndex(host, ".")+1:]
|
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(host)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case err == nil && addr.Is6():
|
|
||||||
host = "[" + addr.String() + "]"
|
|
||||||
case err == nil:
|
|
||||||
host = addr.String()
|
|
||||||
case strings.Trim(host, letters+"0123456789-.") != "": // a character other than these
|
|
||||||
return errOrigin
|
|
||||||
case !strings.ContainsAny(lastPart, letters):
|
|
||||||
return errOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
// The value must be exactly the origin rebuilt from its parts.
|
|
||||||
rebuilt := parsed.Scheme + "://" + host
|
|
||||||
|
|
||||||
port := parsed.Port()
|
|
||||||
if port != "" {
|
|
||||||
_, err := strconv.ParseUint(port, 10, 16)
|
|
||||||
if err != nil || port[0] == '0' || port == defaultPort {
|
|
||||||
return errOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
rebuilt += ":" + port
|
|
||||||
}
|
|
||||||
|
|
||||||
if rebuilt != origin {
|
|
||||||
return errOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateConcurrencyLimits checks that the two upstream connection limits
|
|
||||||
// and the image processing limit are at least 1.
|
|
||||||
func (c *Config) validateConcurrencyLimits() error {
|
|
||||||
if c.UpstreamConnectionsPerHost < 1 {
|
|
||||||
return fmt.Errorf("%s: value %d %w",
|
|
||||||
settingName(keyUpstreamConnectionsPerHost),
|
|
||||||
c.UpstreamConnectionsPerHost, errMustBeAtLeastOne)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamConnections < 1 {
|
|
||||||
return fmt.Errorf("%s: value %d %w",
|
|
||||||
settingName(keyUpstreamConnections),
|
|
||||||
c.UpstreamConnections, errMustBeAtLeastOne)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.MaxConcurrentProcessing < 1 {
|
|
||||||
return fmt.Errorf("%s: value %d %w",
|
|
||||||
settingName(keyMaxConcurrentProcessing),
|
|
||||||
c.MaxConcurrentProcessing, errMustBeAtLeastOne)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,19 +628,6 @@ func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
|
|||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *strictLoader) durationVal(key string, defaultVal time.Duration) time.Duration {
|
|
||||||
if l.err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := getDuration(l.sc, key, defaultVal)
|
|
||||||
if err != nil {
|
|
||||||
l.err = err
|
|
||||||
}
|
|
||||||
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
||||||
if l.err != nil {
|
if l.err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -977,42 +749,6 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDuration returns the duration value for key, or defaultVal if the
|
|
||||||
// key is omitted. A present value must be a positive Go duration string
|
|
||||||
// such as "30s" or "2m", read with time.ParseDuration; a bare number has
|
|
||||||
// no unit and is an error, as is an explicit null.
|
|
||||||
func getDuration(
|
|
||||||
sc *smartconfig.Config, key string, defaultVal time.Duration,
|
|
||||||
) (time.Duration, error) {
|
|
||||||
raw, ok := lookupValue(sc, key)
|
|
||||||
if !ok {
|
|
||||||
return defaultVal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if raw == nil {
|
|
||||||
return 0, errNullConfigValue(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
str, ok := raw.(string)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
|
|
||||||
key, raw, raw, errNotADuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := time.ParseDuration(strings.TrimSpace(str))
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("%s: value %q is %w",
|
|
||||||
settingName(key), str, errNotADuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed <= 0 {
|
|
||||||
return 0, fmt.Errorf("%s: value %q %w",
|
|
||||||
settingName(key), str, errMustBePositive)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBool returns the boolean value for key, or defaultVal if the key
|
// getBool returns the boolean value for key, or defaultVal if the key
|
||||||
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
||||||
// string), or is explicitly null, is an error; numbers are not accepted
|
// string), or is explicitly null, is an error; numbers are not accepted
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
)
|
)
|
||||||
@@ -600,244 +599,3 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
|||||||
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOmittedOriginTimeoutsAndSizeUseDefaults checks that the CORS
|
|
||||||
// origin, the upstream fetch timeout, the upstream response size limit
|
|
||||||
// and the downstream timeout default to the values pixa used before they
|
|
||||||
// could be configured.
|
|
||||||
func TestOmittedOriginTimeoutsAndSizeUseDefaults(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.AccessControlAllowOrigin != "*" {
|
|
||||||
t.Errorf("AccessControlAllowOrigin = %q, want *", c.AccessControlAllowOrigin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamFetchTimeout != 30*time.Second {
|
|
||||||
t.Errorf("UpstreamFetchTimeout = %v, want 30s", c.UpstreamFetchTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamMaxResponseSize != 50<<20 {
|
|
||||||
t.Errorf("UpstreamMaxResponseSize = %d, want %d (50 MiB)",
|
|
||||||
c.UpstreamMaxResponseSize, 50<<20)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.DownstreamTimeout != 60*time.Second {
|
|
||||||
t.Errorf("DownstreamTimeout = %v, want 60s", c.DownstreamTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExplicitOriginTimeoutsAndSizeAreUsed checks that valid values for
|
|
||||||
// the CORS origin, the two timeouts and the response size limit are used
|
|
||||||
// as given. The size is the largest accepted, 1 GiB.
|
|
||||||
func TestExplicitOriginTimeoutsAndSizeAreUsed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine+`
|
|
||||||
access_control_allow_origin: https://app.example.com
|
|
||||||
upstream_fetch_timeout: 10s
|
|
||||||
upstream_max_response_size: 1073741824
|
|
||||||
downstream_timeout: 2m
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("valid config should load, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.AccessControlAllowOrigin != "https://app.example.com" {
|
|
||||||
t.Errorf("AccessControlAllowOrigin = %q, want https://app.example.com",
|
|
||||||
c.AccessControlAllowOrigin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamFetchTimeout != 10*time.Second {
|
|
||||||
t.Errorf("UpstreamFetchTimeout = %v, want 10s", c.UpstreamFetchTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.UpstreamMaxResponseSize != 1073741824 {
|
|
||||||
t.Errorf("UpstreamMaxResponseSize = %d, want 1073741824",
|
|
||||||
c.UpstreamMaxResponseSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.DownstreamTimeout != 2*time.Minute {
|
|
||||||
t.Errorf("DownstreamTimeout = %v, want 2m", c.DownstreamTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOriginWithPortOrAnyOriginIsAccepted checks the other accepted forms
|
|
||||||
// of access_control_allow_origin: "*", an origin with a port, and origins
|
|
||||||
// whose host is an IPv4 or IPv6 address.
|
|
||||||
func TestOriginWithPortOrAnyOriginIsAccepted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, origin := range []string{
|
|
||||||
"*", "http://localhost:3000", "http://192.0.2.1", "http://[2001:db8::1]:8080",
|
|
||||||
} {
|
|
||||||
c, err := configFromYAML(t, signingKeyLine+
|
|
||||||
"access_control_allow_origin: \""+origin+"\"\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("origin %q should be accepted, got error: %v", origin, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.AccessControlAllowOrigin != origin {
|
|
||||||
t.Errorf("AccessControlAllowOrigin = %q, want %q",
|
|
||||||
c.AccessControlAllowOrigin, origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// invalidTimeoutCases are configs where upstream_fetch_timeout or
|
|
||||||
// downstream_timeout is not a positive Go duration string; each must
|
|
||||||
// abort startup naming the key and the value.
|
|
||||||
func invalidTimeoutCases() []abortCase {
|
|
||||||
return []abortCase{
|
|
||||||
{
|
|
||||||
name: "upstream_fetch_timeout not a duration",
|
|
||||||
yaml: signingKeyLine + "upstream_fetch_timeout: soon\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "soon"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_fetch_timeout number without a unit",
|
|
||||||
yaml: signingKeyLine + "upstream_fetch_timeout: 45\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "45"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_fetch_timeout zero",
|
|
||||||
yaml: signingKeyLine + "upstream_fetch_timeout: 0s\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "0s"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_fetch_timeout negative",
|
|
||||||
yaml: signingKeyLine + "upstream_fetch_timeout: -5s\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "-5s"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_fetch_timeout null",
|
|
||||||
yaml: signingKeyLine + "upstream_fetch_timeout: null\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, nullValueText},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "downstream_timeout not a duration",
|
|
||||||
yaml: signingKeyLine + "downstream_timeout: 1 minute\n",
|
|
||||||
wantErrSubstrings: []string{keyDownstreamTimeout, "1 minute"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "downstream_timeout zero",
|
|
||||||
yaml: signingKeyLine + "downstream_timeout: 0s\n",
|
|
||||||
wantErrSubstrings: []string{keyDownstreamTimeout, "0s"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "downstream_timeout negative",
|
|
||||||
yaml: signingKeyLine + "downstream_timeout: -1m\n",
|
|
||||||
wantErrSubstrings: []string{keyDownstreamTimeout, "-1m"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "downstream_timeout null",
|
|
||||||
yaml: signingKeyLine + "downstream_timeout:\n",
|
|
||||||
wantErrSubstrings: []string{keyDownstreamTimeout, nullValueText},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// invalidSizeAndOriginCases are configs where upstream_max_response_size
|
|
||||||
// is not a whole number of bytes from 1 to 1 GiB, or
|
|
||||||
// access_control_allow_origin is neither "*" nor an origin; each must
|
|
||||||
// abort startup naming the key and the value.
|
|
||||||
func invalidSizeAndOriginCases() []abortCase {
|
|
||||||
badOrigins := []string{
|
|
||||||
"", // empty
|
|
||||||
"example.com", // no scheme
|
|
||||||
"https://example.com/images", // a path
|
|
||||||
"https://example.com/", // a trailing slash
|
|
||||||
// The CORS middleware reads a * inside an origin as a pattern
|
|
||||||
// that lets other sites read responses.
|
|
||||||
"https://*",
|
|
||||||
"https://*.example.com",
|
|
||||||
"https://*example.com",
|
|
||||||
"https://a.com,b.com", // two hosts
|
|
||||||
"https://example.com:", // an empty port
|
|
||||||
"https://:8443", // no host
|
|
||||||
"https://example.com:0", // a port below 1
|
|
||||||
"https://example.com:99999", // a port above 65535
|
|
||||||
"https://exämple.com", // a host name that is not ASCII
|
|
||||||
"https://example.com:443", // the default port for https
|
|
||||||
"http://example.com:80", // the default port for http
|
|
||||||
"https://example.com:08080", // a port with a leading zero
|
|
||||||
"https://01.2.3.4", // an IPv4 address with a leading zero
|
|
||||||
"https://10.0.0", // an IPv4 address with three parts
|
|
||||||
"https://192.168.1.256", // an IPv4 address part above 255
|
|
||||||
"https://example.123", // a host name whose last part is a number
|
|
||||||
"https://[0:0:0:0:0:0:0:1]", // an IPv6 address not in its shortest form
|
|
||||||
"file://example.com", // a scheme other than http or https
|
|
||||||
"HTTPS://example.com", // a scheme in upper case
|
|
||||||
"https://Example.com", // a host name in upper case
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := make([]abortCase, 0, len(badOrigins))
|
|
||||||
for _, origin := range badOrigins {
|
|
||||||
cases = append(cases, abortCase{
|
|
||||||
name: "access_control_allow_origin " + origin,
|
|
||||||
yaml: signingKeyLine +
|
|
||||||
"access_control_allow_origin: \"" + origin + "\"\n",
|
|
||||||
wantErrSubstrings: []string{keyAccessControlAllowOrigin, origin},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return append(cases, []abortCase{
|
|
||||||
{
|
|
||||||
name: "access_control_allow_origin null",
|
|
||||||
yaml: signingKeyLine + "access_control_allow_origin: null\n",
|
|
||||||
wantErrSubstrings: []string{keyAccessControlAllowOrigin, nullValueText},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size with a unit",
|
|
||||||
yaml: signingKeyLine + "upstream_max_response_size: 50MB\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "50MB"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size fractional",
|
|
||||||
yaml: signingKeyLine + "upstream_max_response_size: 1.5\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1.5"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size zero",
|
|
||||||
yaml: signingKeyLine + "upstream_max_response_size: 0\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "0"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size negative",
|
|
||||||
yaml: signingKeyLine + "upstream_max_response_size: -1\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "-1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size null",
|
|
||||||
yaml: signingKeyLine + "upstream_max_response_size: null\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, nullValueText},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size above 1 GiB",
|
|
||||||
yaml: signingKeyLine + "upstream_max_response_size: 1073741825\n",
|
|
||||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1073741825"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream_max_response_size largest 64-bit integer",
|
|
||||||
yaml: signingKeyLine +
|
|
||||||
"upstream_max_response_size: 9223372036854775807\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
keyUpstreamMaxResponseSize, "9223372036854775807",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidOriginTimeoutOrSizeAbortsStartup verifies the
|
|
||||||
// no-silent-fallback rule for the CORS origin, the two timeouts and the
|
|
||||||
// response size limit: a value that does not parse or is out of range
|
|
||||||
// aborts startup naming the key and the value.
|
|
||||||
func TestInvalidOriginTimeoutOrSizeAbortsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
runAbortCases(t, append(invalidTimeoutCases(), invalidSizeAndOriginCases()...))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,15 +3,10 @@ package config
|
|||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMain unsets PORT and every PIXA_ environment variable before the
|
// TestMain unsets PORT and every PIXA_ environment variable before the
|
||||||
@@ -67,15 +62,9 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
||||||
t.Setenv("PIXA_ALLOW_HTTP", "true")
|
t.Setenv("PIXA_ALLOW_HTTP", "true")
|
||||||
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_MAX_CONCURRENT_PROCESSING", "3")
|
|
||||||
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")
|
||||||
t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com")
|
|
||||||
t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s")
|
|
||||||
t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576")
|
|
||||||
t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m")
|
|
||||||
|
|
||||||
c, err := newFromSmartConfig(nil)
|
c, err := newFromSmartConfig(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -95,16 +84,10 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
AllowlistHosts: []string{testHostS3, ".example.com"},
|
AllowlistHosts: []string{testHostS3, ".example.com"},
|
||||||
AllowHTTP: true,
|
AllowHTTP: true,
|
||||||
UpstreamConnectionsPerHost: 5,
|
UpstreamConnectionsPerHost: 5,
|
||||||
UpstreamConnections: 10,
|
|
||||||
MaxConcurrentProcessing: 3,
|
|
||||||
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")},
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
AccessControlAllowOrigin: "https://app.example.com",
|
|
||||||
UpstreamFetchTimeout: 10 * time.Second,
|
|
||||||
UpstreamMaxResponseSize: 1048576,
|
|
||||||
DownstreamTimeout: 2 * time.Minute,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(*c, want) {
|
if !reflect.DeepEqual(*c, want) {
|
||||||
@@ -112,114 +95,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnknownPixaVariableAbortsStartup checks that a PIXA_ variable that
|
|
||||||
// is not a setting's variable, such as a misspelled one, aborts startup
|
|
||||||
// naming it, as an unknown config key does, instead of being ignored.
|
|
||||||
func TestUnknownPixaVariableAbortsStartup(t *testing.T) {
|
|
||||||
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
|
||||||
t.Setenv("PIXA_SIGNINGKEY", validTestSigningKey)
|
|
||||||
|
|
||||||
err := validateKnownEnvVars()
|
|
||||||
wantStartupError(t, err, "PIXA_TRUSTED_PROXY", "PIXA_SIGNINGKEY")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPixaPortAbortsStartupPointingToPort checks that PIXA_PORT aborts
|
|
||||||
// startup with a message saying to use PORT, which sets the port.
|
|
||||||
func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
|
||||||
t.Setenv("PIXA_PORT", "9090")
|
|
||||||
|
|
||||||
err := validateKnownEnvVars()
|
|
||||||
wantStartupError(t, err, "PIXA_PORT", "use PORT")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
|
||||||
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
|
||||||
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
|
||||||
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
|
||||||
// A config file's env section loaded by another test can leave a
|
|
||||||
// PIXA_ variable set for the whole process, so every one is unset
|
|
||||||
// here first; t.Setenv restores each when the test ends.
|
|
||||||
for _, entry := range os.Environ() {
|
|
||||||
name, _, _ := strings.Cut(entry, "=")
|
|
||||||
if !strings.HasPrefix(name, "PIXA_") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv(name, "")
|
|
||||||
|
|
||||||
err := os.Unsetenv(name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to unset %s: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
|
||||||
|
|
||||||
for _, name := range envVarNames() {
|
|
||||||
t.Setenv(name, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := validateKnownEnvVars()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PIXA_CONFIG_PATH and every setting's variable "+
|
|
||||||
"must be accepted: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// configFromNew writes yamlContent to a temporary config file, points
|
|
||||||
// PIXA_CONFIG_PATH at it, and runs New, as the server does at startup.
|
|
||||||
// The state directory is a temporary one and the disk cache is off, so
|
|
||||||
// New succeeds unless something in the test is wrong.
|
|
||||||
func configFromNew(t *testing.T, yamlContent string) (*Config, error) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", configPath)
|
|
||||||
t.Setenv("PIXA_STATE_DIR", filepath.Join(tmpDir, "state"))
|
|
||||||
t.Setenv("PIXA_CACHE_MAX_BYTES", "0")
|
|
||||||
|
|
||||||
testLogger, err := logger.New(nil, logger.Params{Globals: &globals.Globals{}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create logger: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return New(nil, Params{Logger: testLogger})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnknownPixaVariableAbortsNew checks that New, which the server
|
|
||||||
// calls at startup, aborts on a misspelled PIXA_ variable.
|
|
||||||
func TestUnknownPixaVariableAbortsNew(t *testing.T) {
|
|
||||||
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
|
||||||
|
|
||||||
_, err := configFromNew(t, signingKeyLine)
|
|
||||||
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnknownPixaVariableInEnvSectionAbortsNew checks that New aborts
|
|
||||||
// on a misspelled PIXA_ name in the config file's env section, which
|
|
||||||
// loading the file sets as an environment variable.
|
|
||||||
func TestUnknownPixaVariableInEnvSectionAbortsNew(t *testing.T) {
|
|
||||||
// The variable must be absent until the file loads. t.Setenv makes
|
|
||||||
// sure the one the file sets is removed when the test ends.
|
|
||||||
t.Setenv("PIXA_TRUSTED_PROXY", "")
|
|
||||||
|
|
||||||
err := os.Unsetenv("PIXA_TRUSTED_PROXY")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to unset PIXA_TRUSTED_PROXY: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = configFromNew(t, signingKeyLine+
|
|
||||||
"env:\n PIXA_TRUSTED_PROXY: 192.0.2.0/24\n")
|
|
||||||
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
|
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
|
||||||
// the port in the config file.
|
// the port in the config file.
|
||||||
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
|
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
|
||||||
@@ -293,30 +168,6 @@ func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
|
|||||||
wantStartupError(t, err, "PIXA_DEBUG", "maybe")
|
wantStartupError(t, err, "PIXA_DEBUG", "maybe")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that
|
|
||||||
// an invalid CORS origin, timeout or response size limit in its variable
|
|
||||||
// aborts startup naming the variable and the value.
|
|
||||||
func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
variable string
|
|
||||||
value string
|
|
||||||
}{
|
|
||||||
{"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"},
|
|
||||||
{"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"},
|
|
||||||
{"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"},
|
|
||||||
{"PIXA_DOWNSTREAM_TIMEOUT", "0s"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.variable, func(t *testing.T) {
|
|
||||||
t.Setenv(tc.variable, tc.value)
|
|
||||||
|
|
||||||
_, err := configFromYAML(t, signingKeyLine)
|
|
||||||
wantStartupError(t, err, tc.variable, tc.value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigFileAloneBehavesAsBefore checks that with no variables set
|
// TestConfigFileAloneBehavesAsBefore checks that with no variables set
|
||||||
// (TestMain unsets them) the config file's values are used and omitted
|
// (TestMain unsets them) the config file's values are used and omitted
|
||||||
// keys take their defaults.
|
// keys take their defaults.
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -15,12 +15,14 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/db/migrations"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // SQLite driver registration
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed schema/*.sql
|
||||||
|
var schemaFS embed.FS
|
||||||
|
|
||||||
// bootstrapVersion is the migration that creates the schema_migrations
|
// bootstrapVersion is the migration that creates the schema_migrations
|
||||||
// table itself. It is applied before the normal migration loop.
|
// table itself. It is applied before the normal migration loop.
|
||||||
const bootstrapVersion = 0
|
const bootstrapVersion = 0
|
||||||
@@ -111,29 +113,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectMigrations reads the embedded migrations directory and returns
|
// collectMigrations reads the embedded schema directory and returns
|
||||||
// migration filenames sorted lexicographically.
|
// migration filenames sorted lexicographically.
|
||||||
func collectMigrations() ([]string, error) {
|
func collectMigrations() ([]string, error) {
|
||||||
entries, err := fs.ReadDir(migrations.FS(), ".")
|
entries, err := schemaFS.ReadDir("schema")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var filenames []string
|
var migrations []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
filenames = append(filenames, entry.Name())
|
migrations = append(migrations, entry.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(filenames)
|
sort.Strings(migrations)
|
||||||
|
|
||||||
return filenames, nil
|
return migrations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||||
// by applying 000_migration.sql if the table is missing.
|
// by applying 000.sql if the table is missing.
|
||||||
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
var tableExists int
|
var tableExists int
|
||||||
|
|
||||||
@@ -148,9 +150,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := fs.ReadFile(migrations.FS(), "000_migration.sql")
|
content, err := schemaFS.ReadFile("schema/000.sql")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
|
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if log != nil {
|
if log != nil {
|
||||||
@@ -175,12 +177,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filenames, err := collectMigrations()
|
migrations, err := collectMigrations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, migration := range filenames {
|
for _, migration := range migrations {
|
||||||
version, parseErr := ParseMigrationVersion(migration)
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return parseErr
|
return parseErr
|
||||||
@@ -206,7 +208,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read and apply migration.
|
// Read and apply migration.
|
||||||
content, readErr := fs.ReadFile(migrations.FS(), migration)
|
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // SQLite driver registration
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestApplyMigrations_SecondRunAppliesNothing applies the migrations twice
|
|
||||||
// to one database file, as happens when pixad starts again on the database
|
|
||||||
// it created, and checks that the second run applies none of them.
|
|
||||||
// ApplyMigrations logs a message starting with "applying" before it runs
|
|
||||||
// any migration, the bootstrap one included.
|
|
||||||
func TestApplyMigrations_SecondRunAppliesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.sqlite3"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open test db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
|
|
||||||
var firstLog bytes.Buffer
|
|
||||||
|
|
||||||
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&firstLog, nil)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first ApplyMigrations failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(firstLog.String(), "applying") {
|
|
||||||
t.Fatalf("first ApplyMigrations logged no applied migration:\n%s",
|
|
||||||
firstLog.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var secondLog bytes.Buffer
|
|
||||||
|
|
||||||
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&secondLog, nil)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second ApplyMigrations failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(secondLog.String(), "applying") {
|
|
||||||
t.Errorf("second ApplyMigrations ran a migration again:\n%s",
|
|
||||||
secondLog.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Package migrations provides the embedded SQL migration files.
|
|
||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed *.sql
|
|
||||||
var files embed.FS
|
|
||||||
|
|
||||||
// FS returns the embedded filesystem containing the migration files.
|
|
||||||
func FS() fs.FS {
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
@@ -103,8 +103,7 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ToImageRequest converts the payload to an ImageRequest.
|
// ToImageRequest converts the payload to an ImageRequest.
|
||||||
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
// Applies default values for omitted optional fields.
|
||||||
// that never expires, gives the zero Expires.
|
|
||||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
@@ -121,11 +120,6 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
fitMode = DefaultFitMode
|
fitMode = DefaultFitMode
|
||||||
}
|
}
|
||||||
|
|
||||||
var expires time.Time
|
|
||||||
if p.ExpiresAt != 0 {
|
|
||||||
expires = time.Unix(p.ExpiresAt, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imgcache.ImageRequest{
|
||||||
SourceHost: p.SourceHost,
|
SourceHost: p.SourceHost,
|
||||||
SourcePath: p.SourcePath,
|
SourcePath: p.SourcePath,
|
||||||
@@ -137,7 +131,6 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
Expires: expires,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-133
@@ -1,12 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,20 +14,6 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errInvalidFormField reports a generator form field, or the q or exp
|
|
||||||
// parameter of /v1/image/, whose value is non-numeric or out of range. The
|
|
||||||
// offending field name is wrapped in so the response can name it.
|
|
||||||
var errInvalidFormField = errors.New("invalid")
|
|
||||||
|
|
||||||
// Bounds for the generator's quality and ttl fields; the quality bounds also
|
|
||||||
// apply to the q parameter of /v1/image/. maxTTL is in seconds: the expiry
|
|
||||||
// calculation time.Duration(ttl) * time.Second overflows above it.
|
|
||||||
const (
|
|
||||||
minQuality = 1
|
|
||||||
maxQuality = 100
|
|
||||||
maxTTL = int(math.MaxInt64 / time.Second)
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleRoot serves the login page or generator page based on authentication state.
|
// HandleRoot serves the login page or generator page based on authentication state.
|
||||||
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -119,26 +101,18 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
|
||||||
http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
|
||||||
if err != nil {
|
|
||||||
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
|
||||||
http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
|
||||||
http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,40 +140,17 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
|
||||||
// non-numeric or out-of-range width, height, quality or ttl, or an
|
|
||||||
// unrecognized fit mode, is a client error naming the offending field. The
|
|
||||||
// format field is passed through unchecked.
|
|
||||||
func buildGeneratePayload(
|
func buildGeneratePayload(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*encurl.Payload, time.Time, int, error) {
|
) (*encurl.Payload, time.Time, int) {
|
||||||
width, err := parseFormDimension(form, "width")
|
width, _ := strconv.Atoi(form.Get("width"))
|
||||||
if err != nil {
|
height, _ := strconv.Atoi(form.Get("height"))
|
||||||
return nil, time.Time{}, 0, err
|
quality, _ := strconv.Atoi(form.Get("quality"))
|
||||||
}
|
ttl, _ := strconv.Atoi(form.Get("ttl"))
|
||||||
|
|
||||||
height, err := parseFormDimension(form, "height")
|
if quality <= 0 {
|
||||||
if err != nil {
|
quality = 85
|
||||||
return nil, time.Time{}, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
quality, err := parseFormInt(form, "quality",
|
|
||||||
encurl.DefaultQuality, minQuality, maxQuality)
|
|
||||||
if err != nil {
|
|
||||||
return nil, time.Time{}, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl, err := parseFormInt(form, "ttl", 0, 0, maxTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, time.Time{}, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fitMode := imgcache.FitMode(form.Get("fit"))
|
|
||||||
|
|
||||||
err = imgcache.ValidateFitMode(fitMode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, time.Time{}, 0,
|
|
||||||
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -220,57 +171,11 @@ func buildGeneratePayload(
|
|||||||
Height: height,
|
Height: height,
|
||||||
Format: imgcache.ImageFormat(form.Get("format")),
|
Format: imgcache.ImageFormat(form.Get("format")),
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: imgcache.FitMode(form.Get("fit")),
|
||||||
ExpiresAt: expiresAtUnix,
|
ExpiresAt: expiresAtUnix,
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, expiresAt, ttl, nil
|
return payload, expiresAt, ttl
|
||||||
}
|
|
||||||
|
|
||||||
// parseFormDimension reads an optional width or height form field. An empty
|
|
||||||
// value means "original size" (0). A non-numeric value, or one
|
|
||||||
// imgcache.ValidateDimension rejects, is an error naming the field.
|
|
||||||
func parseFormDimension(form url.Values, field string) (int, error) {
|
|
||||||
raw := form.Get(field)
|
|
||||||
if raw == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = imgcache.ValidateDimension(field, value)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFormInt reads an optional integer form field or URL query parameter,
|
|
||||||
// returning def when the field is empty and an error naming the field when the
|
|
||||||
// value is non-numeric or outside minValue to maxValue.
|
|
||||||
func parseFormInt(
|
|
||||||
form url.Values, field string, def, minValue, maxValue int,
|
|
||||||
) (int, error) {
|
|
||||||
raw := form.Get(field)
|
|
||||||
if raw == "" {
|
|
||||||
return def, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
|
||||||
}
|
|
||||||
|
|
||||||
if value < minValue || value > maxValue {
|
|
||||||
return 0, fmt.Errorf("%w %s: must be from %d to %d",
|
|
||||||
errInvalidFormField, field, minValue, maxValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatorData holds template data for the generator page.
|
// generatorData holds template data for the generator page.
|
||||||
@@ -310,15 +215,6 @@ func (s *Handlers) renderLogin(
|
|||||||
|
|
||||||
func (s *Handlers) renderGenerator(
|
func (s *Handlers) renderGenerator(
|
||||||
w http.ResponseWriter, r *http.Request, data *generatorData,
|
w http.ResponseWriter, r *http.Request, data *generatorData,
|
||||||
) {
|
|
||||||
s.renderGeneratorStatus(w, r, data, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderGeneratorStatus renders the generator page with an explicit HTTP
|
|
||||||
// status; a rejected form uses 400. The page is rendered into a buffer before
|
|
||||||
// the status is written, so a template failure can still answer 500.
|
|
||||||
func (s *Handlers) renderGeneratorStatus(
|
|
||||||
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
|
|
||||||
) {
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
@@ -328,29 +224,17 @@ func (s *Handlers) renderGeneratorStatus(
|
|||||||
|
|
||||||
data.CSRFField = csrfField(r)
|
data.CSRFField = csrfField(r)
|
||||||
|
|
||||||
var page bytes.Buffer
|
err := templates.Render(w, "generator.html", data)
|
||||||
|
|
||||||
err := templates.Render(&page, "generator.html", data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(status)
|
|
||||||
|
|
||||||
_, err = page.WriteTo(w)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to write generator page", "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string,
|
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
|
||||||
form url.Values, status int,
|
|
||||||
) {
|
) {
|
||||||
s.renderGeneratorStatus(w, r, &generatorData{
|
s.renderGenerator(w, r, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
@@ -359,7 +243,7 @@ func (s *Handlers) renderGeneratorWithForm(
|
|||||||
FormQuality: form.Get("quality"),
|
FormQuality: form.Get("quality"),
|
||||||
FormFit: form.Get("fit"),
|
FormFit: form.Get("fit"),
|
||||||
FormTTL: form.Get("ttl"),
|
FormTTL: form.Get("ttl"),
|
||||||
}, status)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Generator form field names, and a value that is not a number.
|
|
||||||
const (
|
|
||||||
widthField = "width"
|
|
||||||
heightField = "height"
|
|
||||||
qualityField = "quality"
|
|
||||||
ttlField = "ttl"
|
|
||||||
fitField = "fit"
|
|
||||||
notANumber = "abc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// generatePost submits the /generate form with a valid session and CSRF token
|
|
||||||
// plus the caller's extra fields, returning the recorder.
|
|
||||||
func generatePost(
|
|
||||||
t *testing.T, extra url.Values,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
h, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
sessionCookie := newSessionCookie(t, h)
|
|
||||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
|
||||||
cookies = append(cookies, sessionCookie)
|
|
||||||
|
|
||||||
form := url.Values{
|
|
||||||
sourceURLField: {testSourceURL},
|
|
||||||
csrfTokenField: {token},
|
|
||||||
}
|
|
||||||
maps.Copy(form, extra)
|
|
||||||
|
|
||||||
return postForm(srv, "/generate", cookies, form)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
|
|
||||||
// rejected with 400 naming the field rather than being coerced to 0 and
|
|
||||||
// minting a 0-width token.
|
|
||||||
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := generatePost(t, url.Values{"width": {"abc"}})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
|
||||||
t.Error("a token was generated for non-numeric width")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
|
|
||||||
// MaxDimension is rejected at generation time so an unusable token cannot be
|
|
||||||
// minted.
|
|
||||||
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := generatePost(t, url.Values{"width": {"100000"}})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
|
||||||
t.Error("a token was generated for an over-limit width")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostRejectsBadField verifies that each generator field whose
|
|
||||||
// value is not a number, out of range, or unrecognized is rejected with 400,
|
|
||||||
// mints no token, and is named in the error shown on the page.
|
|
||||||
func TestGeneratePostRejectsBadField(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
field, value, wantError string
|
|
||||||
}{
|
|
||||||
{widthField, notANumber, "invalid width: not a number"},
|
|
||||||
{widthField, "-1", "width is negative"},
|
|
||||||
{widthField, "8193", "width is above 8192"},
|
|
||||||
{heightField, notANumber, "invalid height: not a number"},
|
|
||||||
{heightField, "8193", "height is above 8192"},
|
|
||||||
{qualityField, notANumber, "invalid quality: not a number"},
|
|
||||||
{qualityField, "0", "invalid quality: must be from 1 to 100"},
|
|
||||||
{qualityField, "101", "invalid quality: must be from 1 to 100"},
|
|
||||||
{ttlField, notANumber, "invalid ttl: not a number"},
|
|
||||||
{ttlField, "-1", "invalid ttl: must be from 0 to"},
|
|
||||||
{ttlField, "10000000000", "invalid ttl: must be from 0 to"},
|
|
||||||
{fitField, "bogus", "invalid fit mode: bogus"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.field+"="+tt.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := generatePost(t, url.Values{tt.field: {tt.value}})
|
|
||||||
body := rec.Body.String()
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(body, "/v1/e/") {
|
|
||||||
t.Error("a token was generated")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(body, tt.wantError) {
|
|
||||||
t.Errorf("page does not show %q", tt.wantError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
|
||||||
// takes the default, and that the largest accepted width, height, quality and
|
|
||||||
// ttl are accepted with an expiry still in the future.
|
|
||||||
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parsed, err := url.Parse(testSourceURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("url.Parse() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("empty form: error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Quality != encurl.DefaultQuality {
|
|
||||||
t.Errorf("empty quality gave %d, want %d",
|
|
||||||
payload.Quality, encurl.DefaultQuality)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
|
||||||
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
|
||||||
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
|
||||||
qualityField: {strconv.Itoa(maxQuality)},
|
|
||||||
ttlField: {strconv.Itoa(maxTTL)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("largest accepted values: error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !expiresAt.After(time.Now()) {
|
|
||||||
t.Errorf("ttl %d gave expiry %v, want a time in the future",
|
|
||||||
maxTTL, expiresAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -106,24 +106,20 @@ func (s *Handlers) initImageService() error {
|
|||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := httpfetcher.DefaultConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
|
||||||
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
|
||||||
|
|
||||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
}
|
}
|
||||||
|
|
||||||
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
|
||||||
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||||
|
|
||||||
// Create the service
|
// Create the service
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Allowlist: s.config.AllowlistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
Logger: s.log,
|
||||||
Logger: s.log,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -277,120 +276,3 @@ func TestHandleImage_ETagHeader(t *testing.T) {
|
|||||||
t.Errorf("ETag should be quoted, got %q", etag)
|
t.Errorf("ETag should be quoted, got %q", etag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image
|
|
||||||
// route rejects an unrecognized fit mode with 400.
|
|
||||||
func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
|
||||||
|
|
||||||
status := getImage(t, fix,
|
|
||||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus")
|
|
||||||
|
|
||||||
if status != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_InvalidQuery_Returns400 verifies that the plain image route
|
|
||||||
// answers a q that is not a whole number from 1 to 100, an empty one
|
|
||||||
// included, with 400 naming q and the value, a parameter given more than once
|
|
||||||
// with 400 naming it, and a query string that cannot be decoded with 400
|
|
||||||
// showing it, instead of serving the image at the default quality 85 or at
|
|
||||||
// the first value given.
|
|
||||||
func TestHandleImage_InvalidQuery_Returns400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
query, wantError string
|
|
||||||
}{
|
|
||||||
{"q=banana", `invalid q: not a number, got "banana"`},
|
|
||||||
{"q=0", `invalid q: must be from 1 to 100, got "0"`},
|
|
||||||
{"q=101", `invalid q: must be from 1 to 100, got "101"`},
|
|
||||||
{"q=", `invalid q: not a number, got ""`},
|
|
||||||
{"q=80&q=500", `invalid q: given more than once`},
|
|
||||||
{"q=80&q=", `invalid q: given more than once`},
|
|
||||||
{"fit=cover&fit=contain", `invalid fit: given more than once`},
|
|
||||||
{"q=80%", `invalid query string "q=80%": invalid URL escape "%"`},
|
|
||||||
{
|
|
||||||
"q=50;fit=contain",
|
|
||||||
`invalid query string "q=50;fit=contain": ` +
|
|
||||||
`invalid semicolon separator in query`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.query, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
||||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.Error != tt.wantError {
|
|
||||||
t.Errorf("error = %q, want %q", body.Error, tt.wantError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_EmptyFit_Returns400 verifies that the plain image route
|
|
||||||
// answers a fit that is in the URL but empty with 400 naming fit, instead of
|
|
||||||
// serving the image as cover. Only a fit missing from the URL means cover.
|
|
||||||
func TestHandleImage_EmptyFit_Returns400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
||||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantError := `invalid fit: not a fit mode, got ""`
|
|
||||||
if body.Error != wantError {
|
|
||||||
t.Errorf("error = %q, want %q", body.Error, wantError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-115
@@ -2,17 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,66 +89,39 @@ func (s *Handlers) parseImageRequest(
|
|||||||
// Convert to ImageRequest
|
// Convert to ImageRequest
|
||||||
req := parsed.ToImageRequest()
|
req := parsed.ToImageRequest()
|
||||||
|
|
||||||
// Parse signature params from query string. r.URL.Query() would silently
|
// Parse signature params from query string
|
||||||
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
query := r.URL.Query()
|
||||||
// at 85; a query string that cannot be decoded is refused instead. A
|
req.Signature = query.Get("sig")
|
||||||
// parameter given more than once is refused too, as only its first value
|
|
||||||
// would be read.
|
|
||||||
query, err := url.ParseQuery(r.URL.RawQuery)
|
|
||||||
if err != nil {
|
|
||||||
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
|
||||||
r.URL.RawQuery, err), http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
if expStr := query.Get("exp"); expStr != "" {
|
||||||
|
exp, parseErr := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if parseErr == nil {
|
||||||
|
req.Expires = time.Unix(exp, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, values := range query {
|
// Parse optional quality and fit params
|
||||||
if len(values) > 1 {
|
if qStr := query.Get("q"); qStr != "" {
|
||||||
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
q, parseErr := strconv.Atoi(qStr)
|
||||||
name), http.StatusBadRequest)
|
if parseErr == nil && q > 0 && q <= 100 {
|
||||||
|
req.Quality = q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fit := query.Get("fit"); fit != "" {
|
||||||
|
req.FitMode = imgcache.FitMode(fit)
|
||||||
|
|
||||||
|
fitErr := imgcache.ValidateFitMode(req.FitMode)
|
||||||
|
if fitErr != nil {
|
||||||
|
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Signature = query.Get("sig")
|
// Default quality if not set
|
||||||
|
if req.Quality == 0 {
|
||||||
req.Expires, err = parseExpires(query)
|
req.Quality = 85
|
||||||
if err != nil {
|
|
||||||
s.respondError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional quality and fit params. Only a q missing from the URL is
|
|
||||||
// 85. A q in the URL that is not a whole number from 1 to 100, an empty
|
|
||||||
// one included, is refused, checked as the generator checks its quality
|
|
||||||
// field; that check alone would take an empty q as missing.
|
|
||||||
qStr := query.Get("q")
|
|
||||||
if query.Has("q") && qStr == "" {
|
|
||||||
s.respondError(w, `invalid q: not a number, got ""`,
|
|
||||||
http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Quality, err = parseFormInt(query, "q",
|
|
||||||
encurl.DefaultQuality, minQuality, maxQuality)
|
|
||||||
if err != nil {
|
|
||||||
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
|
||||||
http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
|
||||||
// fit mode is refused by the fit-mode check below; that check would take an
|
|
||||||
// empty fit as missing, so an empty one is refused here.
|
|
||||||
req.FitMode = imgcache.FitMode(query.Get("fit"))
|
|
||||||
if query.Has("fit") && req.FitMode == "" {
|
|
||||||
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
@@ -160,41 +129,9 @@ func (s *Handlers) parseImageRequest(
|
|||||||
req.FitMode = imgcache.FitCover
|
req.FitMode = imgcache.FitCover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
|
||||||
// route. Dimensions are already bounded by the path parser above; this
|
|
||||||
// also rejects an unrecognized fit mode with 400 instead of letting it
|
|
||||||
// reach the processor as a 500.
|
|
||||||
err = imgcache.ValidateImageRequest(req)
|
|
||||||
if err != nil {
|
|
||||||
s.respondError(w, "invalid image request: "+err.Error(),
|
|
||||||
http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return req, true
|
return req, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
|
||||||
// missing from the URL gives the zero time, which the signature check takes
|
|
||||||
// as no expiration. An exp in the URL that is not a whole number, an empty
|
|
||||||
// one included, is an error naming exp and the value.
|
|
||||||
func parseExpires(query url.Values) (time.Time, error) {
|
|
||||||
if !query.Has("exp") {
|
|
||||||
return time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
expStr := query.Get("exp")
|
|
||||||
|
|
||||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("%w exp: not a number, got %q",
|
|
||||||
errInvalidFormField, expStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Unix(exp, 0), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondImageError maps image retrieval errors to HTTP responses.
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
func (s *Handlers) respondImageError(
|
func (s *Handlers) respondImageError(
|
||||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
@@ -218,35 +155,9 @@ func (s *Handlers) respondImageError(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages) {
|
|
||||||
s.respondError(w, "server busy, try again later",
|
|
||||||
http.StatusServiceUnavailable)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cacheControl returns the Cache-Control header for an image served through a
|
|
||||||
// URL that expires at expires, or never when expires is the zero time. A cache
|
|
||||||
// may keep the image for a year, but not past the URL's expiry, after which
|
|
||||||
// pixa refuses the URL. The seconds left are rounded down and never negative.
|
|
||||||
// immutable only stops revalidation while the image is fresh, so it also ends
|
|
||||||
// at the expiry.
|
|
||||||
func cacheControl(expires time.Time) string {
|
|
||||||
const oneYear = 365 * 24 * time.Hour
|
|
||||||
|
|
||||||
maxAge := oneYear
|
|
||||||
|
|
||||||
if !expires.IsZero() {
|
|
||||||
maxAge = min(maxAge, max(time.Until(expires), 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeImageResponse writes headers and streams the image content,
|
// writeImageResponse writes headers and streams the image content,
|
||||||
// handling conditional and HEAD requests.
|
// handling conditional and HEAD requests.
|
||||||
func (s *Handlers) writeImageResponse(
|
func (s *Handlers) writeImageResponse(
|
||||||
@@ -262,7 +173,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if resp.ETag != "" {
|
if resp.ETag != "" {
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"testing/fstest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
|
||||||
|
|
||||||
// photoPath is the path of the JPEG that newSignedHostServer serves.
|
|
||||||
const photoPath = "/images/photo.jpg"
|
|
||||||
|
|
||||||
// newSignedHostServer returns a router for both image routes, and the Handlers
|
|
||||||
// behind it, whose fetcher serves a JPEG at photoPath on signedHost. signedHost
|
|
||||||
// is not on the allowlist, so a /v1/image/ URL for it is served only with a
|
|
||||||
// valid signature.
|
|
||||||
func newSignedHostServer(t *testing.T) (*Handlers, http.Handler) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
|
||||||
StateDir: t.TempDir(),
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
|
||||||
|
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
||||||
Cache: cache,
|
|
||||||
Fetcher: newMockFetcher(fstest.MapFS{
|
|
||||||
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
|
||||||
}),
|
|
||||||
SigningKey: testSigningKey,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &Handlers{
|
|
||||||
log: slog.New(slog.DiscardHandler),
|
|
||||||
imgSvc: svc,
|
|
||||||
encGen: encGen,
|
|
||||||
}
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", h.HandleImage())
|
|
||||||
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
|
||||||
|
|
||||||
return h, r
|
|
||||||
}
|
|
||||||
|
|
||||||
// getMaxAge sends a GET for target to srv, requires a 200, and returns the
|
|
||||||
// max-age of the response's Cache-Control header, which must read
|
|
||||||
// "public, max-age=<seconds>, immutable".
|
|
||||||
func getMaxAge(t *testing.T, srv http.Handler, target string) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
header := rec.Header().Get("Cache-Control")
|
|
||||||
t.Logf("GET %s: %d, Cache-Control: %s", target, rec.Code, header)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, hasPrefix := strings.CutPrefix(header, "public, max-age=")
|
|
||||||
value, hasSuffix := strings.CutSuffix(value, ", immutable")
|
|
||||||
|
|
||||||
maxAge, err := strconv.Atoi(value)
|
|
||||||
if !hasPrefix || !hasSuffix || err != nil {
|
|
||||||
t.Fatalf("Cache-Control = %q, want public, max-age=<seconds>, immutable",
|
|
||||||
header)
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxAge
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_SignedURL_MaxAgeEndsAtExp verifies that an image served
|
|
||||||
// through a signed URL expiring in 60 seconds may be cached for at most those
|
|
||||||
// 60 seconds. The lower bound of 50 shows the max-age is the time left, not 0.
|
|
||||||
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newSignedHostServer(t)
|
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
||||||
SourceHost: signedHost,
|
|
||||||
SourcePath: photoPath,
|
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
|
||||||
Format: imgcache.FormatJPEG,
|
|
||||||
}, time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxAge := getMaxAge(t, srv, signedURL)
|
|
||||||
if maxAge < 50 || maxAge > 60 {
|
|
||||||
t.Errorf("max-age = %d, want 50 to 60", maxAge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_AllowlistedHost_MaxAge verifies the max-age of an image from
|
|
||||||
// an allowlisted host, which is served without checking sig or exp. A URL with
|
|
||||||
// no exp may be cached for a year. A URL whose exp has passed is the one request
|
|
||||||
// that reaches the header after its expiry, and must get 0, never less.
|
|
||||||
func TestHandleImage_AllowlistedHost_MaxAge(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
pastExp := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
query string
|
|
||||||
wantMaxAge int
|
|
||||||
}{
|
|
||||||
{"no exp", "", 31536000},
|
|
||||||
{"exp already past", "?exp=" + pastExp, 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
||||||
|
|
||||||
maxAge := getMaxAge(t, r,
|
|
||||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg"+tt.query)
|
|
||||||
if maxAge != tt.wantMaxAge {
|
|
||||||
t.Errorf("max-age = %d, want %d", maxAge, tt.wantMaxAge)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImageEnc_MaxAge verifies that an image served through an encrypted
|
|
||||||
// URL with a 60 second TTL may be cached for at most those 60 seconds, that one
|
|
||||||
// with a two-year TTL may be cached for a year, and that one made without a
|
|
||||||
// TTL, which never expires, may be cached for a year.
|
|
||||||
func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
expiresAt int64
|
|
||||||
wantAtLeast int
|
|
||||||
wantAtMost int
|
|
||||||
}{
|
|
||||||
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
|
|
||||||
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
|
|
||||||
{"no TTL", 0, 31536000, 31536000},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newSignedHostServer(t)
|
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
|
||||||
SourceHost: signedHost,
|
|
||||||
SourcePath: photoPath,
|
|
||||||
Width: 50,
|
|
||||||
Height: 50,
|
|
||||||
Format: imgcache.FormatJPEG,
|
|
||||||
ExpiresAt: tt.expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Generate() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxAge := getMaxAge(t, srv, "/v1/e/"+token+"/img.jpg")
|
|
||||||
if maxAge < tt.wantAtLeast || maxAge > tt.wantAtMost {
|
|
||||||
t.Errorf("max-age = %d, want %d to %d",
|
|
||||||
maxAge, tt.wantAtLeast, tt.wantAtMost)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
|
||||||
)
|
|
||||||
|
|
||||||
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
|
||||||
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
|
||||||
// request that passes the signature check gets 502 from the failed fetch, and
|
|
||||||
// one that fails the check gets 401.
|
|
||||||
const signedHost = "signed.example.com"
|
|
||||||
|
|
||||||
// getImage sends a GET for target to the image route of fix and returns the
|
|
||||||
// response status.
|
|
||||||
func getImage(t *testing.T, fix *testFixtures, target string) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
t.Logf("GET %s: %d", target, rec.Code)
|
|
||||||
|
|
||||||
return rec.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
|
|
||||||
// and fit cover, the values the route uses when a URL has no q or fit, and
|
|
||||||
// sends that signature with each q and fit below.
|
|
||||||
func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
||||||
sig := signer.Sign(&signature.Request{
|
|
||||||
SourceHost: signedHost,
|
|
||||||
SourcePath: "/images/photo.jpg",
|
|
||||||
Width: 50,
|
|
||||||
Height: 50,
|
|
||||||
Format: string(imgcache.FormatJPEG),
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: string(imgcache.FitCover),
|
|
||||||
Expires: expires,
|
|
||||||
})
|
|
||||||
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
|
||||||
signedHost, sig, expires.Unix())
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
query string
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{"no q or fit", "", http.StatusBadGateway},
|
|
||||||
{"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway},
|
|
||||||
{"replayed with q=40", "&q=40", http.StatusUnauthorized},
|
|
||||||
{"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
status := getImage(t, setupTestHandler(t), signedURL+tt.query)
|
|
||||||
if status != tt.wantStatus {
|
|
||||||
t.Errorf("status = %d, want %d", status, tt.wantStatus)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
|
||||||
// service's signed-URL generator to the route.
|
|
||||||
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
quality int
|
|
||||||
fitMode imgcache.FitMode
|
|
||||||
}{
|
|
||||||
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
|
||||||
{"quality and fit unset", 0, ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
|
||||||
|
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
||||||
SourceHost: signedHost,
|
|
||||||
SourcePath: "/images/photo.jpg",
|
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
|
||||||
Format: imgcache.FormatJPEG,
|
|
||||||
Quality: tt.quality,
|
|
||||||
FitMode: tt.fitMode,
|
|
||||||
}, time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := getImage(t, fix, signedURL)
|
|
||||||
if status != http.StatusBadGateway {
|
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is
|
|
||||||
// not a whole number, and one whose exp is empty. Each is refused with 400
|
|
||||||
// naming exp and the value, not with the 401 a URL without exp still gets.
|
|
||||||
func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
query string
|
|
||||||
wantStatus int
|
|
||||||
wantError string
|
|
||||||
}{
|
|
||||||
{"sig=x&exp=banana", http.StatusBadRequest,
|
|
||||||
`invalid exp: not a number, got "banana"`},
|
|
||||||
{"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`},
|
|
||||||
{"sig=x", http.StatusUnauthorized, "unauthorized"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.query, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
||||||
"/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rec.Code != tt.wantStatus || body.Error != tt.wantError {
|
|
||||||
t.Errorf("got %d %q, want %d %q",
|
|
||||||
rec.Code, body.Error, tt.wantStatus, tt.wantError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,19 +50,6 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// Convert payload to ImageRequest
|
// Convert payload to ImageRequest
|
||||||
req := payload.ToImageRequest()
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
// Apply the same dimension and fit-mode bounds as the plain image
|
|
||||||
// route: a sealed payload is trusted for its origin, not for staying
|
|
||||||
// within limits, so an over-limit size or unknown fit mode is a 400
|
|
||||||
// here rather than an out-of-memory or a 500 from the processor.
|
|
||||||
err = imgcache.ValidateImageRequest(req)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Debug("encrypted URL failed validation", "error", err)
|
|
||||||
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
|
||||||
http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
s.log.Debug("encrypted image request",
|
s.log.Debug("encrypted image request",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
@@ -90,8 +76,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache headers: max-age ends at the URL's expiry
|
// Cache headers - encrypted URLs can be cached since they're immutable
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
// Stream the response
|
// Stream the response
|
||||||
@@ -125,10 +111,6 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
|||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
||||||
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages):
|
|
||||||
s.respondError(w, "server busy, try again later",
|
|
||||||
http.StatusServiceUnavailable)
|
|
||||||
default:
|
default:
|
||||||
s.log.Error("image request failed", "error", err)
|
s.log.Error("image request failed", "error", err)
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newEncTestServer builds a router serving the encrypted-URL route with a
|
|
||||||
// generator seeded by the shared test signing key. The image service is left
|
|
||||||
// nil: these tests exercise validation that rejects a token before any image
|
|
||||||
// is fetched, so the handler must never reach the service.
|
|
||||||
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &Handlers{
|
|
||||||
log: slog.New(slog.DiscardHandler),
|
|
||||||
encGen: encGen,
|
|
||||||
}
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
|
||||||
|
|
||||||
return encGen, r
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEncToken issues a GET for the given token and returns the recorder.
|
|
||||||
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
|
||||||
// token requesting a dimension beyond MaxDimension is rejected with 400
|
|
||||||
// instead of reaching the image processor and libvips.
|
|
||||||
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photo.jpg",
|
|
||||||
Width: 100000,
|
|
||||||
Height: 100000,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Generate() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := getEncToken(srv, token)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
|
||||||
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
|
||||||
// as a 500 from the image processor's default branch.
|
|
||||||
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photo.jpg",
|
|
||||||
Width: 800,
|
|
||||||
Height: 600,
|
|
||||||
FitMode: imgcache.FitMode("bogus"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Generate() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := getEncToken(srv, token)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestServerBusyAnswers503 checks that both image routes answer 503 with
|
|
||||||
// a clear error when the image service gives up waiting for a free upstream
|
|
||||||
// connection or processing slot, wrapped as the service wraps them.
|
|
||||||
func TestServerBusyAnswers503(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
|
||||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
|
|
||||||
|
|
||||||
for _, err := range []error{
|
|
||||||
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
|
||||||
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
|
||||||
} {
|
|
||||||
plain := httptest.NewRecorder()
|
|
||||||
h.respondImageError(plain, req, err)
|
|
||||||
|
|
||||||
encrypted := httptest.NewRecorder()
|
|
||||||
h.handleImageError(encrypted, err)
|
|
||||||
|
|
||||||
for route, rec := range map[string]*httptest.ResponseRecorder{
|
|
||||||
"/v1/image/": plain, "/v1/e/": encrypted,
|
|
||||||
} {
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Errorf("%s for %v: status = %d, want %d",
|
|
||||||
route, err, rec.Code, http.StatusServiceUnavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
|
||||||
t.Errorf("%s for %v: body = %q, want the server busy error",
|
|
||||||
route, err, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
||||||
// protection, connection limits per host and for all hosts together, and
|
// protection, per-host connection limits, and content-type validation.
|
||||||
// content-type validation.
|
|
||||||
package httpfetcher
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -29,13 +28,8 @@ const (
|
|||||||
DefaultIdleConnTimeout = 90 * time.Second
|
DefaultIdleConnTimeout = 90 * time.Second
|
||||||
DefaultMaxRedirects = 10
|
DefaultMaxRedirects = 10
|
||||||
DefaultMaxConnectionsPerHost = 20
|
DefaultMaxConnectionsPerHost = 20
|
||||||
DefaultMaxConnections = 64
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
|
||||||
// MaxConnections fetches are already in progress.
|
|
||||||
const ConnectionWaitTimeout = 10 * time.Second
|
|
||||||
|
|
||||||
// MIME content types.
|
// MIME content types.
|
||||||
const (
|
const (
|
||||||
contentTypeJPEG = "image/jpeg"
|
contentTypeJPEG = "image/jpeg"
|
||||||
@@ -76,7 +70,6 @@ var (
|
|||||||
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
||||||
ErrUpstreamError = errors.New("upstream server error")
|
ErrUpstreamError = errors.New("upstream server error")
|
||||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||||
ErrTooManyConnections = errors.New("too many concurrent upstream connections")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Internal fetcher errors.
|
// Internal fetcher errors.
|
||||||
@@ -129,9 +122,6 @@ type Config struct {
|
|||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
MaxConnectionsPerHost int
|
||||||
// MaxConnections limits concurrent connections to all upstream hosts
|
|
||||||
// together.
|
|
||||||
MaxConnections int
|
|
||||||
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||||
// dialer, in addition to the always-enforced built-in ranges.
|
// dialer, in addition to the always-enforced built-in ranges.
|
||||||
BlockedNetworks []netip.Prefix
|
BlockedNetworks []netip.Prefix
|
||||||
@@ -153,22 +143,15 @@ func DefaultConfig() *Config {
|
|||||||
},
|
},
|
||||||
AllowHTTP: false,
|
AllowHTTP: false,
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
MaxConnections: DefaultMaxConnections,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
// HTTPFetcher implements Fetcher with SSRF protection and per-host connection limits.
|
||||||
// per host and for all hosts together.
|
|
||||||
type HTTPFetcher struct {
|
type HTTPFetcher struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
config *Config
|
config *Config
|
||||||
hostSems map[string]chan struct{} // per-host semaphores
|
hostSems map[string]chan struct{} // per-host semaphores
|
||||||
hostSemMu sync.Mutex // protects hostSems map
|
hostSemMu sync.Mutex // protects hostSems map
|
||||||
// allHostsSemaphore has one slot per connection allowed to all hosts
|
|
||||||
// together (config.MaxConnections).
|
|
||||||
allHostsSemaphore chan struct{}
|
|
||||||
// connectionWaitTimeout is ConnectionWaitTimeout; tests shorten it.
|
|
||||||
connectionWaitTimeout time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new HTTPFetcher with SSRF protection.
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
@@ -209,18 +192,13 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &HTTPFetcher{
|
return &HTTPFetcher{
|
||||||
client: client,
|
client: client,
|
||||||
config: config,
|
config: config,
|
||||||
hostSems: make(map[string]chan struct{}),
|
hostSems: make(map[string]chan struct{}),
|
||||||
allHostsSemaphore: make(chan struct{}, config.MaxConnections),
|
|
||||||
connectionWaitTimeout: ConnectionWaitTimeout,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves content from the given URL with SSRF protection. When
|
// Fetch retrieves content from the given URL with SSRF protection.
|
||||||
// MaxConnections fetches are already in progress, it waits up to
|
|
||||||
// ConnectionWaitTimeout for one to finish, then fails with
|
|
||||||
// ErrTooManyConnections.
|
|
||||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||||
// Validate URL before making request
|
// Validate URL before making request
|
||||||
err := validateURL(ctx, url, f.config.AllowHTTP)
|
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||||
@@ -228,17 +206,24 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := f.acquireConnection(ctx, extractHost(url))
|
// Extract host for rate limiting
|
||||||
if err != nil {
|
host := extractHost(url)
|
||||||
return nil, err
|
|
||||||
|
// Acquire semaphore slot for this host
|
||||||
|
sem := f.getHostSemaphore(host)
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
// Acquired slot
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we fail before returning a result, release the connection
|
// If we fail before returning a result, release the slot
|
||||||
success := false
|
success := false
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if !success {
|
if !success {
|
||||||
release()
|
<-sem
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -282,52 +267,17 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
|
result, err := f.buildResult(resp, remoteAddr, fetchDuration, sem)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark success so defer doesn't release the connection; closing the
|
// Mark success so defer doesn't release the semaphore
|
||||||
// result's Content does
|
|
||||||
success = true
|
success = true
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquireConnection takes a slot for host, then one of the slots shared by
|
|
||||||
// all hosts, and returns the func that gives both back. The host's slot
|
|
||||||
// comes first, so fetches queued for one busy host hold no shared slot.
|
|
||||||
// Only the wait for a shared slot is bounded: after connectionWaitTimeout
|
|
||||||
// it fails with ErrTooManyConnections.
|
|
||||||
func (f *HTTPFetcher) acquireConnection(
|
|
||||||
ctx context.Context, host string,
|
|
||||||
) (func(), error) {
|
|
||||||
hostSem := f.getHostSemaphore(host)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case hostSem <- struct{}{}:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case f.allHostsSemaphore <- struct{}{}:
|
|
||||||
case <-time.After(f.connectionWaitTimeout):
|
|
||||||
<-hostSem
|
|
||||||
|
|
||||||
return nil, ErrTooManyConnections
|
|
||||||
case <-ctx.Done():
|
|
||||||
<-hostSem
|
|
||||||
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
<-hostSem
|
|
||||||
<-f.allHostsSemaphore
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||||
f.hostSemMu.Lock()
|
f.hostSemMu.Lock()
|
||||||
@@ -343,12 +293,12 @@ func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildResult validates the upstream response and assembles a FetchResult
|
// buildResult validates the upstream response and assembles a FetchResult
|
||||||
// whose Content calls release when closed.
|
// whose Content releases the host semaphore slot when closed.
|
||||||
func (f *HTTPFetcher) buildResult(
|
func (f *HTTPFetcher) buildResult(
|
||||||
resp *http.Response,
|
resp *http.Response,
|
||||||
remoteAddr string,
|
remoteAddr string,
|
||||||
fetchDuration time.Duration,
|
fetchDuration time.Duration,
|
||||||
release func(),
|
sem chan struct{},
|
||||||
) (*FetchResult, error) {
|
) (*FetchResult, error) {
|
||||||
// Extract HTTP version (strip "HTTP/" prefix)
|
// Extract HTTP version (strip "HTTP/" prefix)
|
||||||
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
||||||
@@ -383,7 +333,7 @@ func (f *HTTPFetcher) buildResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &FetchResult{
|
return &FetchResult{
|
||||||
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, release},
|
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
|
||||||
ContentLength: resp.ContentLength,
|
ContentLength: resp.ContentLength,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Headers: resp.Header,
|
Headers: resp.Header,
|
||||||
@@ -624,18 +574,17 @@ func (r *limitedReader) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// semaphoreReleasingReadCloser releases the fetch's connection slots when
|
// semaphoreReleasingReadCloser releases a semaphore slot when closed.
|
||||||
// closed.
|
|
||||||
type semaphoreReleasingReadCloser struct {
|
type semaphoreReleasingReadCloser struct {
|
||||||
*limitedReader
|
*limitedReader
|
||||||
|
|
||||||
closer io.Closer
|
closer io.Closer
|
||||||
release func()
|
sem chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *semaphoreReleasingReadCloser) Close() error {
|
func (r *semaphoreReleasingReadCloser) Close() error {
|
||||||
err := r.closer.Close()
|
err := r.closer.Close()
|
||||||
r.release()
|
<-r.sem // Release semaphore slot
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package httpfetcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
|
||||||
// port. Each port is a different host to the per-host limit, while the test
|
|
||||||
// dialer sends every port to the one test server.
|
|
||||||
func imageURLOnPort(port int) string {
|
|
||||||
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
|
||||||
"/image"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultConfigMaxConnections(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if got := DefaultConfig().MaxConnections; got != DefaultMaxConnections {
|
|
||||||
t.Errorf("MaxConnections = %d, want %d", got, DefaultMaxConnections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
|
||||||
// counts the fetches to every host together, apart from the per-host
|
|
||||||
// limit: with MaxConnections at 2 and two responses open from two hosts, a
|
|
||||||
// fetch from a third host, which has nothing open, waits the whole wait
|
|
||||||
// timeout and fails with ErrTooManyConnections. Closing one response lets
|
|
||||||
// it through.
|
|
||||||
func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := startUpstream(t)
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxConnections = 2
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second Fetch() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = second.Content.Close() }()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
|
|
||||||
if !errors.Is(err, ErrTooManyConnections) {
|
|
||||||
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if waited := time.Since(start); waited < f.connectionWaitTimeout {
|
|
||||||
t.Errorf("third Fetch() failed after %v, before waiting %v",
|
|
||||||
waited, f.connectionWaitTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if held := semLen(f, testPublicHost+":83"); held != 0 {
|
|
||||||
t.Errorf("the refused fetch kept its host's slot: %d held", held)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = first.Content.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("close first body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = third.Content.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchFreesHostSlotWhenContextEndsWaitingForConnection checks that a
|
|
||||||
// fetch whose request context ends while it waits for a connection shared
|
|
||||||
// by all hosts gives its host's slot back. With MaxConnections at 1 and one
|
|
||||||
// response open, a fetch from another host takes that host's slot and waits;
|
|
||||||
// its context ends long before the 10 second wait timeout.
|
|
||||||
func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := startUpstream(t)
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxConnections = 1
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = first.Content.Close() }()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(82))
|
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if held := semLen(f, testPublicHost+":82"); held != 0 {
|
|
||||||
t.Errorf("the fetch kept its host's slot after its context ended: "+
|
|
||||||
"%d held", held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchReleasesConnectionOnError checks that a fetch that fails after
|
|
||||||
// taking its connection gives it back: with MaxConnections at 1, the slot
|
|
||||||
// must be free after the failure and the next fetch must succeed.
|
|
||||||
func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
|
||||||
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
|
|
||||||
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
|
||||||
// dialer refuses every host but testPublicHost.
|
|
||||||
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := startUpstream(t)
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxConnections = 1
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), tc.url)
|
|
||||||
if !errors.Is(err, tc.want) {
|
|
||||||
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if held := len(f.allHostsSemaphore); held != 0 {
|
|
||||||
t.Fatalf("connection still held after the error: %d held", held)
|
|
||||||
}
|
|
||||||
|
|
||||||
res := fetchImage(t, f, "/image")
|
|
||||||
_ = res.Content.Close()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
)
|
)
|
||||||
@@ -19,21 +17,11 @@ import (
|
|||||||
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
||||||
var vipsOnce sync.Once
|
var vipsOnce sync.Once
|
||||||
|
|
||||||
// initVips initializes libvips with quiet logging, one worker thread per
|
// initVips initializes libvips with quiet logging.
|
||||||
// image and no operation cache. Process already works on one image per CPU
|
|
||||||
// by default, so more threads per image would only compete for the CPUs.
|
|
||||||
// Each request decodes different source bytes, so the operation cache
|
|
||||||
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
|
||||||
// repeated requests are served from pixa's disk cache instead.
|
|
||||||
func initVips() {
|
func initVips() {
|
||||||
vipsOnce.Do(func() {
|
vipsOnce.Do(func() {
|
||||||
vips.LoggingSettings(nil, vips.LogLevelError)
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
||||||
vips.Startup(&vips.Config{
|
vips.Startup(nil)
|
||||||
ConcurrencyLevel: 1,
|
|
||||||
MaxCacheSize: 0,
|
|
||||||
MaxCacheMem: 0,
|
|
||||||
MaxCacheFiles: 0,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,23 +106,9 @@ var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
|||||||
// not supported.
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
|
||||||
// processed and none finishes within ProcessingWaitTimeout.
|
|
||||||
var ErrTooManyImages = errors.New("too many images being processed at once")
|
|
||||||
|
|
||||||
// ProcessingWaitTimeout is how long Process waits for a free slot when
|
|
||||||
// MaxConcurrentProcessing images are already being processed.
|
|
||||||
const ProcessingWaitTimeout = 10 * time.Second
|
|
||||||
|
|
||||||
// ImageProcessor implements image transformation using libvips via govips.
|
// ImageProcessor implements image transformation using libvips via govips.
|
||||||
type ImageProcessor struct {
|
type ImageProcessor struct {
|
||||||
maxInputBytes int64
|
maxInputBytes int64
|
||||||
// processingSemaphore has one slot per image that may be processed at
|
|
||||||
// once. Process holds a slot from before it reads its input until it
|
|
||||||
// returns, so the input, the decoded image and the output all count.
|
|
||||||
processingSemaphore chan struct{}
|
|
||||||
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
|
||||||
processingWaitTimeout time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Params holds configuration for creating an ImageProcessor.
|
// Params holds configuration for creating an ImageProcessor.
|
||||||
@@ -143,9 +117,6 @@ type Params struct {
|
|||||||
// MaxInputBytes is the maximum allowed input size in bytes.
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
// If <= 0, DefaultMaxInputBytes is used.
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
MaxInputBytes int64
|
MaxInputBytes int64
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
|
||||||
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
|
||||||
MaxConcurrentProcessing int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new image processor with the given parameters.
|
// New creates a new image processor with the given parameters.
|
||||||
@@ -158,34 +129,17 @@ func New(params Params) *ImageProcessor {
|
|||||||
maxInputBytes = DefaultMaxInputBytes
|
maxInputBytes = DefaultMaxInputBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
|
||||||
if maxConcurrentProcessing <= 0 {
|
|
||||||
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
maxInputBytes: maxInputBytes,
|
maxInputBytes: maxInputBytes,
|
||||||
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
|
||||||
processingWaitTimeout: ProcessingWaitTimeout,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request. When
|
// Process transforms an image according to the request.
|
||||||
// MaxConcurrentProcessing images are already being processed, it waits up
|
|
||||||
// to ProcessingWaitTimeout for one to finish, then fails with
|
|
||||||
// ErrTooManyImages.
|
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *Request,
|
req *Request,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
release, err := p.acquireSlot(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
// Read input with a size limit to prevent unbounded memory consumption.
|
// Read input with a size limit to prevent unbounded memory consumption.
|
||||||
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
||||||
// the limit without consuming additional memory.
|
// the limit without consuming additional memory.
|
||||||
@@ -207,13 +161,6 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
defer img.Close()
|
defer img.Close()
|
||||||
|
|
||||||
// Turn the image upright now: encode strips the EXIF orientation tag,
|
|
||||||
// and sizes below must be worked out on the upright image.
|
|
||||||
err = img.AutoRotate()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get original dimensions
|
// Get original dimensions
|
||||||
origWidth := img.Width()
|
origWidth := img.Width()
|
||||||
origHeight := img.Height()
|
origHeight := img.Height()
|
||||||
@@ -331,29 +278,6 @@ func FormatToMIME(format Format) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
|
||||||
// processingWaitTimeout for one to free up, and returns the func that gives
|
|
||||||
// it back. A free slot is taken even when ctx has ended; only the wait for
|
|
||||||
// one stops when ctx ends, as the rest of Process does not check ctx.
|
|
||||||
func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
|
||||||
release := func() { <-p.processingSemaphore }
|
|
||||||
|
|
||||||
select {
|
|
||||||
case p.processingSemaphore <- struct{}{}:
|
|
||||||
return release, nil
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case p.processingSemaphore <- struct{}{}:
|
|
||||||
return release, nil
|
|
||||||
case <-time.After(p.processingWaitTimeout):
|
|
||||||
return nil, ErrTooManyImages
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// detectFormat returns the format string from a vips image.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
||||||
format := img.Format()
|
format := img.Format()
|
||||||
@@ -480,21 +404,6 @@ func (p *ImageProcessor) encode(
|
|||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stripping drops the ICC profile as well, and clients show an image
|
|
||||||
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
|
||||||
// libvips' built-in profile; govips' own sRGB path variable is set on
|
|
||||||
// first use but read without a lock, so concurrent requests race on it.
|
|
||||||
if img.HasICCProfile() {
|
|
||||||
err := img.TransformICCProfileWithFallback("srgb", "srgb")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
output, _, err := img.Export(¶ms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
@@ -563,152 +561,3 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
|||||||
|
|
||||||
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndDecode runs input through Process and decodes the output with
|
|
||||||
// vips, so a test can inspect the image a client would receive.
|
|
||||||
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
|
||||||
context.Background(), bytes.NewReader(input), req,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = result.Content.Close() }()
|
|
||||||
|
|
||||||
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 decode output: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(output.Close)
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageProcessor_StripsEXIF(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// gps-exif.jpg carries GPS coordinates, a camera make, model and serial
|
|
||||||
// number, and a capture time.
|
|
||||||
input, err := os.ReadFile("testdata/gps-exif.jpg")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture, err := vips.NewImageFromBuffer(input)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to decode test JPEG: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(fixture.Close)
|
|
||||||
|
|
||||||
if !slices.Contains(fixture.GetFields(), "exif-ifd3-GPSLatitude") {
|
|
||||||
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
|
||||||
}
|
|
||||||
|
|
||||||
formats := []Format{
|
|
||||||
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, format := range formats {
|
|
||||||
t.Run(string(format), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: format})
|
|
||||||
|
|
||||||
if output.HasExif() {
|
|
||||||
t.Errorf("output has EXIF: %v", output.GetExif())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageProcessor_AppliesEXIFOrientation(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
size Size
|
|
||||||
wantW int
|
|
||||||
wantH int
|
|
||||||
}{
|
|
||||||
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
|
|
||||||
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{
|
|
||||||
Size: tt.size,
|
|
||||||
Format: FormatPNG,
|
|
||||||
})
|
|
||||||
|
|
||||||
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
|
||||||
t.Fatalf("output is %dx%d, want %dx%d",
|
|
||||||
output.Width(), output.Height(), tt.wantW, tt.wantH)
|
|
||||||
}
|
|
||||||
|
|
||||||
top, err := output.GetPoint(tt.wantW/2, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetPoint() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bottom, err := output.GetPoint(tt.wantW/2, tt.wantH-1)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageProcessor_ConvertsWideGamutToSRGB(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: FormatPNG})
|
|
||||||
|
|
||||||
if output.HasICCProfile() {
|
|
||||||
t.Error("output has an ICC profile")
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
package imageprocessor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"testing/iotest"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errTestReadFailed is the error the unreadable test input returns.
|
|
||||||
var errTestReadFailed = errors.New("test input cannot be read")
|
|
||||||
|
|
||||||
// readingCounter counts the Process calls reading their input at the same
|
|
||||||
// time and remembers the most there ever were.
|
|
||||||
type readingCounter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
reading int
|
|
||||||
most int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *readingCounter) start() {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.reading++
|
|
||||||
c.most = max(c.most, c.reading)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *readingCounter) stop() {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.reading--
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *readingCounter) mostReading() int {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
return c.most
|
|
||||||
}
|
|
||||||
|
|
||||||
// gatedReader is a Process input. Its first Read counts the call in,
|
|
||||||
// reports it on entered and blocks until gate is closed; it counts the call
|
|
||||||
// out when it returns io.EOF. Process reads its input only while it holds a
|
|
||||||
// processing slot, so the count never goes above MaxConcurrentProcessing.
|
|
||||||
type gatedReader struct {
|
|
||||||
data *bytes.Reader
|
|
||||||
gate <-chan struct{}
|
|
||||||
entered chan<- struct{}
|
|
||||||
counter *readingCounter
|
|
||||||
started bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *gatedReader) Read(p []byte) (int, error) {
|
|
||||||
if !r.started {
|
|
||||||
r.started = true
|
|
||||||
r.counter.start()
|
|
||||||
|
|
||||||
r.entered <- struct{}{}
|
|
||||||
|
|
||||||
<-r.gate
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := r.data.Read(p)
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
r.counter.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// smallJPEGRequest asks for a 5x5 JPEG.
|
|
||||||
func smallJPEGRequest() *Request {
|
|
||||||
return &Request{
|
|
||||||
Size: Size{Width: 5, Height: 5},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processInBackground runs Process on reader in a new goroutine and sends
|
|
||||||
// its error on results.
|
|
||||||
func processInBackground(
|
|
||||||
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
|
||||||
) {
|
|
||||||
go func() {
|
|
||||||
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
|
||||||
if err == nil {
|
|
||||||
_ = result.Content.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
results <- err
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForEntries fails the test unless count Process calls report on
|
|
||||||
// entered within a few seconds.
|
|
||||||
func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for range count {
|
|
||||||
select {
|
|
||||||
case <-entered:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("Process calls did not start reading their input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, limit := range []int{0, -1} {
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
||||||
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
|
||||||
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
|
||||||
limit, got, runtime.GOMAXPROCS(0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 3})
|
|
||||||
if got := cap(proc.processingSemaphore); got != 3 {
|
|
||||||
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
|
||||||
// than MaxConcurrentProcessing allows and holds the first ones inside
|
|
||||||
// Process until the test lets them go. No more than the limit may be
|
|
||||||
// working at once, and the calls held back must wait for a slot and then
|
|
||||||
// succeed.
|
|
||||||
func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
limit = 2
|
|
||||||
calls = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
||||||
input := createTestJPEG(t, 50, 50)
|
|
||||||
|
|
||||||
counter := &readingCounter{}
|
|
||||||
gate := make(chan struct{})
|
|
||||||
entered := make(chan struct{}, calls)
|
|
||||||
results := make(chan error, calls)
|
|
||||||
|
|
||||||
openGate := sync.OnceFunc(func() { close(gate) })
|
|
||||||
t.Cleanup(openGate)
|
|
||||||
|
|
||||||
for range calls {
|
|
||||||
processInBackground(proc, &gatedReader{
|
|
||||||
data: bytes.NewReader(input), gate: gate, entered: entered,
|
|
||||||
counter: counter,
|
|
||||||
}, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForEntries(t, entered, limit)
|
|
||||||
|
|
||||||
// A call beyond the limit would start reading its input now.
|
|
||||||
select {
|
|
||||||
case <-entered:
|
|
||||||
t.Fatalf("a Process call started while %d were already working", limit)
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
openGate()
|
|
||||||
|
|
||||||
for range calls {
|
|
||||||
err := <-results
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Process() error = %v, want nil once a slot is free", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if most := counter.mostReading(); most > limit {
|
|
||||||
t.Errorf("%d Process calls worked at once, want at most %d", most, limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessWaitsThenFailsWhenNoSlotFrees holds the only slot and checks
|
|
||||||
// that another call waits the whole wait timeout, then fails with
|
|
||||||
// ErrTooManyImages instead of processing anyway.
|
|
||||||
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 1})
|
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
|
||||||
|
|
||||||
input := createTestJPEG(t, 10, 10)
|
|
||||||
|
|
||||||
gate := make(chan struct{})
|
|
||||||
entered := make(chan struct{}, 1)
|
|
||||||
held := make(chan error, 1)
|
|
||||||
|
|
||||||
openGate := sync.OnceFunc(func() { close(gate) })
|
|
||||||
t.Cleanup(openGate)
|
|
||||||
|
|
||||||
processInBackground(proc, &gatedReader{
|
|
||||||
data: bytes.NewReader(input), gate: gate, entered: entered,
|
|
||||||
counter: &readingCounter{},
|
|
||||||
}, held)
|
|
||||||
waitForEntries(t, entered, 1)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
|
||||||
smallJPEGRequest())
|
|
||||||
if !errors.Is(err, ErrTooManyImages) {
|
|
||||||
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if waited := time.Since(start); waited < proc.processingWaitTimeout {
|
|
||||||
t.Errorf("Process() failed after %v, before waiting %v",
|
|
||||||
waited, proc.processingWaitTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
openGate()
|
|
||||||
|
|
||||||
err = <-held
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Process() holding the slot: error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessReleasesSlotOnError checks that Process gives its slot back
|
|
||||||
// when it fails, whether it fails early or late: with one slot, the slot
|
|
||||||
// must be free after the failure and the next call must succeed.
|
|
||||||
func TestProcessReleasesSlotOnError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
valid := createTestJPEG(t, 10, 10)
|
|
||||||
|
|
||||||
unsupported := smallJPEGRequest()
|
|
||||||
unsupported.Format = "bmp"
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
input io.Reader
|
|
||||||
req *Request
|
|
||||||
// want is the error Process must return; nil means any error.
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "input cannot be read",
|
|
||||||
input: iotest.ErrReader(errTestReadFailed),
|
|
||||||
req: smallJPEGRequest(),
|
|
||||||
want: errTestReadFailed,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "input over the byte limit",
|
|
||||||
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
|
||||||
req: smallJPEGRequest(),
|
|
||||||
want: ErrInputDataTooLarge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "input not an image",
|
|
||||||
input: strings.NewReader("not an image"),
|
|
||||||
req: smallJPEGRequest(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "output format not supported",
|
|
||||||
input: bytes.NewReader(valid),
|
|
||||||
req: unsupported,
|
|
||||||
want: ErrUnsupportedOutputFormat,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
|
||||||
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
|
||||||
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if held := len(proc.processingSemaphore); held != 0 {
|
|
||||||
t.Fatalf("slot still held after the error: %d held", held)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
|
||||||
smallJPEGRequest())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process() after the error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = result.Content.Close()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWaitForProcessing holds a processing slot with a Process call that
|
|
||||||
// cannot finish reading its input. WaitForProcessing must report that image
|
|
||||||
// when its context ends first, wait for it otherwise, return 0 once it has
|
|
||||||
// finished, and give back the slots it took while waiting.
|
|
||||||
func TestWaitForProcessing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 2})
|
|
||||||
|
|
||||||
gate := make(chan struct{})
|
|
||||||
entered := make(chan struct{}, 1)
|
|
||||||
results := make(chan error, 1)
|
|
||||||
|
|
||||||
openGate := sync.OnceFunc(func() { close(gate) })
|
|
||||||
t.Cleanup(openGate)
|
|
||||||
|
|
||||||
processInBackground(proc, &gatedReader{
|
|
||||||
data: bytes.NewReader(createTestJPEG(t, 10, 10)), gate: gate,
|
|
||||||
entered: entered, counter: &readingCounter{},
|
|
||||||
}, results)
|
|
||||||
waitForEntries(t, entered, 1)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
stillProcessing := proc.WaitForProcessing(ctx)
|
|
||||||
t.Logf("WaitForProcessing() after its context ended: %d", stillProcessing)
|
|
||||||
|
|
||||||
if stillProcessing != 1 {
|
|
||||||
t.Errorf("WaitForProcessing() after its context ended = %d, want 1",
|
|
||||||
stillProcessing)
|
|
||||||
}
|
|
||||||
|
|
||||||
waited := make(chan int, 1)
|
|
||||||
|
|
||||||
go func() { waited <- proc.WaitForProcessing(t.Context()) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case got := <-waited:
|
|
||||||
t.Fatalf("WaitForProcessing() = %d while an image was being processed",
|
|
||||||
got)
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
openGate()
|
|
||||||
|
|
||||||
err := <-results
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Process() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case got := <-waited:
|
|
||||||
if got != 0 {
|
|
||||||
t.Errorf("WaitForProcessing() once processing finished = %d, want 0",
|
|
||||||
got)
|
|
||||||
}
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("WaitForProcessing() did not return once processing finished")
|
|
||||||
}
|
|
||||||
|
|
||||||
if held := len(proc.processingSemaphore); held != 0 {
|
|
||||||
t.Errorf("%d slots still held after WaitForProcessing() returned", held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 811 B |
+45
-147
@@ -14,7 +14,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,10 +26,6 @@ var (
|
|||||||
// HTTP status code for successful fetch.
|
// HTTP status code for successful fetch.
|
||||||
const httpStatusOK = 200
|
const httpStatusOK = 200
|
||||||
|
|
||||||
// metaCacheSize is how many variants' content types metaCache holds. A
|
|
||||||
// variant not among them is served as before, reading its .meta file.
|
|
||||||
const metaCacheSize = 10000
|
|
||||||
|
|
||||||
// CacheConfig holds cache configuration.
|
// CacheConfig holds cache configuration.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
StateDir string
|
StateDir string
|
||||||
@@ -54,6 +49,12 @@ type CacheConfig struct {
|
|||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// variantMeta stores content type for fast cache hits without reading .meta file.
|
||||||
|
type variantMeta struct {
|
||||||
|
ContentType string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -75,10 +76,9 @@ type Cache struct {
|
|||||||
evictionStarted bool
|
evictionStarted bool
|
||||||
evictionStopOnce sync.Once
|
evictionStopOnce sync.Once
|
||||||
|
|
||||||
// metaCache holds the content types of the variants most recently
|
// In-memory cache of variant metadata (content type, size) to avoid
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
// reading .meta files
|
||||||
// It never stands in for the variant file, which is always opened.
|
metaCache map[VariantKey]variantMeta
|
||||||
metaCache *lru.Cache[VariantKey, string]
|
|
||||||
|
|
||||||
// contentLocks serializes StoreSource and evictSourceBlob per
|
// contentLocks serializes StoreSource and evictSourceBlob per
|
||||||
// content hash, closing the race window between an eviction's row
|
// content hash, closing the race window between an eviction's row
|
||||||
@@ -101,11 +101,6 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &Cache{
|
c := &Cache{
|
||||||
db: db,
|
db: db,
|
||||||
config: config,
|
config: config,
|
||||||
@@ -114,7 +109,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
evictionPressure: make(chan struct{}, 1),
|
evictionPressure: make(chan struct{}, 1),
|
||||||
evictionStop: make(chan struct{}),
|
evictionStop: make(chan struct{}),
|
||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: metaCache,
|
metaCache: make(map[VariantKey]variantMeta),
|
||||||
contentLocks: newContentLock(),
|
contentLocks: newContentLock(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +125,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(
|
variants, err := NewVariantStorage(
|
||||||
filepath.Join(config.StateDir, "cache", "variants"), log,
|
filepath.Join(config.StateDir, "cache", "variants"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||||
@@ -182,30 +177,13 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVariant returns a reader, size, and content type for a cached
|
// GetVariant returns a reader, size, and content type for a cached variant.
|
||||||
// variant. The content type comes from metaCache, or else from the
|
|
||||||
// variant's .meta file and is then kept in metaCache. A variant with
|
|
||||||
// no .meta file is served as application/octet-stream, which is not
|
|
||||||
// kept.
|
|
||||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil, 0, "", ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType, known := c.metaCache.Get(cacheKey)
|
return c.variants.LoadWithMeta(cacheKey)
|
||||||
if !known {
|
|
||||||
return c.loadVariantWithMeta(cacheKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
// The file is gone, e.g. deleted outside pixa
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata. On a
|
// StoreSource stores fetched source content and metadata. On a
|
||||||
@@ -285,7 +263,23 @@ func (c *Cache) StoreSource(
|
|||||||
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.writeMetadataSidecar(req, pathHash, contentHash, result)
|
// Store metadata JSON file
|
||||||
|
meta := &SourceMetadata{
|
||||||
|
Host: req.SourceHost,
|
||||||
|
Path: req.SourcePath,
|
||||||
|
Query: req.SourceQuery,
|
||||||
|
ContentHash: string(contentHash),
|
||||||
|
StatusCode: result.StatusCode,
|
||||||
|
ContentType: result.ContentType,
|
||||||
|
ContentLength: result.ContentLength,
|
||||||
|
ResponseHeaders: result.Headers,
|
||||||
|
FetchedAt: time.Now().UTC().Unix(),
|
||||||
|
FetchDurationMs: result.FetchDurationMs,
|
||||||
|
RemoteAddr: result.RemoteAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failure here is non-fatal; the metadata is in the database.
|
||||||
|
_ = c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||||
|
|
||||||
c.notifyWritePressure()
|
c.notifyWritePressure()
|
||||||
|
|
||||||
@@ -308,8 +302,6 @@ func (c *Cache) StoreVariant(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Add(cacheKey, contentType)
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -407,16 +399,13 @@ func (c *Cache) GetSourceMetadataID(
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSourceContent returns a reader for cached source content by its hash,
|
// GetSourceContent returns a reader for cached source content by its hash.
|
||||||
// and the content's size in bytes.
|
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
|
||||||
func (c *Cache) GetSourceContent(
|
|
||||||
contentHash ContentHash,
|
|
||||||
) (io.ReadCloser, int64, error) {
|
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil, 0, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.srcContent.LoadWithSize(contentHash)
|
return c.srcContent.Load(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired removes expired entries from the cache.
|
// CleanExpired removes expired entries from the cache.
|
||||||
@@ -446,22 +435,13 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count and size the cached source images and processed variants. A
|
// Get actual item count and total size from content tables
|
||||||
// disabled cache holds none, whatever rows an earlier run left.
|
_ = c.db.QueryRowContext(ctx,
|
||||||
if !c.disabled {
|
`SELECT COUNT(*) FROM request_cache`,
|
||||||
err = c.db.QueryRowContext(ctx, `
|
).Scan(&stats.TotalItems)
|
||||||
SELECT (SELECT COUNT(*) FROM source_content)
|
_ = c.db.QueryRowContext(ctx,
|
||||||
+ (SELECT COUNT(*) FROM variant_content)
|
`SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`,
|
||||||
`).Scan(&stats.TotalItems)
|
).Scan(&stats.TotalSizeBytes)
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to count cache items for stats", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to sum cache size for stats", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute hit rate as a ratio
|
// Compute hit rate as a ratio
|
||||||
if stats.HitCount+stats.MissCount > 0 {
|
if stats.HitCount+stats.MissCount > 0 {
|
||||||
@@ -473,17 +453,15 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
|
|
||||||
// IncrementStats increments cache statistics.
|
// IncrementStats increments cache statistics.
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||||
var err error
|
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET hit_count = hit_count + 1,
|
SET hit_count = hit_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET miss_count = miss_count + 1,
|
SET miss_count = miss_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -491,90 +469,14 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fetchBytes > 0 {
|
if fetchBytes > 0 {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
SET upstream_fetch_count = upstream_fetch_count + 1,
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`, fetchBytes)
|
`, fetchBytes)
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to count upstream fetch",
|
|
||||||
"fetch_bytes", fetchBytes, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
|
||||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
|
||||||
UPDATE cache_stats
|
|
||||||
SET transform_count = transform_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to count transform", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
|
||||||
// hold: it reads the content type from the variant's .meta file and
|
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
|
||||||
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
|
||||||
// as one can between a store's writing of the variant file and of its
|
|
||||||
// .meta file, serves application/octet-stream and keeps nothing, so
|
|
||||||
// metaCache only ever holds a type read from a .meta file or passed to
|
|
||||||
// StoreVariant.
|
|
||||||
func (c *Cache) loadVariantWithMeta(
|
|
||||||
cacheKey VariantKey,
|
|
||||||
) (io.ReadCloser, int64, string, error) {
|
|
||||||
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType == "" {
|
|
||||||
return reader, size, fallbackContentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
|
||||||
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
|
||||||
// database.
|
|
||||||
func (c *Cache) writeMetadataSidecar(
|
|
||||||
req *ImageRequest,
|
|
||||||
pathHash PathHash,
|
|
||||||
contentHash ContentHash,
|
|
||||||
result *httpfetcher.FetchResult,
|
|
||||||
) {
|
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: req.SourceHost,
|
|
||||||
Path: req.SourcePath,
|
|
||||||
Query: req.SourceQuery,
|
|
||||||
ContentHash: string(contentHash),
|
|
||||||
StatusCode: result.StatusCode,
|
|
||||||
ContentType: result.ContentType,
|
|
||||||
ContentLength: result.ContentLength,
|
|
||||||
ResponseHeaders: result.Headers,
|
|
||||||
FetchedAt: time.Now().UTC().Unix(),
|
|
||||||
FetchDurationMs: result.FetchDurationMs,
|
|
||||||
RemoteAddr: result.RemoteAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to write metadata sidecar",
|
|
||||||
"host", req.SourceHost, "path_hash", pathHash, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,14 +528,10 @@ func (c *Cache) checkNegativeCache(
|
|||||||
// Check if expired
|
// Check if expired
|
||||||
if time.Now().After(expiresAt) {
|
if time.Now().After(expiresAt) {
|
||||||
// Clean up expired entry
|
// Clean up expired entry
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
DELETE FROM negative_cache
|
DELETE FROM negative_cache
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to delete expired negative cache entry",
|
|
||||||
"host", req.SourceHost, "path", req.SourcePath, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ const tempFilePrefix = ".tmp-"
|
|||||||
// to each variant file.
|
// to each variant file.
|
||||||
const variantMetaSuffix = ".meta"
|
const variantMetaSuffix = ".meta"
|
||||||
|
|
||||||
// fallbackContentType is the content type given to a variant file that
|
// fallbackContentType is recorded when a reconciled variant file has
|
||||||
// has no readable .meta sidecar, when it is served or reconciled.
|
// no readable .meta sidecar.
|
||||||
const fallbackContentType = "application/octet-stream"
|
const fallbackContentType = "application/octet-stream"
|
||||||
|
|
||||||
// UsageBytes returns the total number of bytes of cache content
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
@@ -271,9 +271,7 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
|
|
||||||
// evictVariant removes one variant: accounting row first, then the
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
// content and .meta files, so the database never references a deleted
|
// content and .meta files, so the database never references a deleted
|
||||||
// file. The metaCache entry goes before the files; a GetVariant that
|
// file.
|
||||||
// read them just before may put it back, and the next GetVariant then
|
|
||||||
// fails to open the file and removes it again.
|
|
||||||
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||||
_, err := c.db.ExecContext(ctx,
|
_, err := c.db.ExecContext(ctx,
|
||||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||||
@@ -281,8 +279,6 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
|||||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
err = c.variants.DeleteWithMeta(cacheKey)
|
err = c.variants.DeleteWithMeta(cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -59,24 +59,6 @@ func ValidateFitMode(fit FitMode) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateImageRequest checks a request's width and height with
|
|
||||||
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
|
|
||||||
// /v1/image/ route and the encrypted /v1/e/ route validate through this
|
|
||||||
// function so a request from either source enforces identical bounds.
|
|
||||||
func ValidateImageRequest(req *ImageRequest) error {
|
|
||||||
err := ValidateDimension("width", req.Size.Width)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ValidateDimension("height", req.Size.Height)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ValidateFitMode(req.FitMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageRequest represents a request for a processed image
|
// ImageRequest represents a request for a processed image
|
||||||
type ImageRequest struct {
|
type ImageRequest struct {
|
||||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
// SourceHost is the origin host (e.g., "cdn.example.com")
|
||||||
@@ -95,8 +77,7 @@ type ImageRequest struct {
|
|||||||
FitMode FitMode
|
FitMode FitMode
|
||||||
// Signature is the HMAC signature for non-allowlisted hosts
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
// Expires is the signature expiration timestamp
|
||||||
// of an encrypted URL; the zero time if it has none
|
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
@@ -163,11 +144,9 @@ type ImageCache interface {
|
|||||||
|
|
||||||
// CacheStats contains cache statistics
|
// CacheStats contains cache statistics
|
||||||
type CacheStats struct {
|
type CacheStats struct {
|
||||||
// TotalItems is the number of cached source images plus processed
|
// TotalItems is the number of cached items
|
||||||
// variants
|
|
||||||
TotalItems int64
|
TotalItems int64
|
||||||
// TotalSizeBytes is the total size of cached source images and
|
// TotalSizeBytes is the total size of cached content
|
||||||
// processed variants
|
|
||||||
TotalSizeBytes int64
|
TotalSizeBytes int64
|
||||||
// HitCount is the number of cache hits
|
// HitCount is the number of cache hits
|
||||||
HitCount int64
|
HitCount int64
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"image/jpeg"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
|
||||||
// keep the photo's aspect ratio.
|
|
||||||
func widthOnlyRequest(fixtures *TestFixtures, width int) *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: fixtures.GoodHost,
|
|
||||||
SourcePath: testPathPhoto,
|
|
||||||
Size: Size{Width: width},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// holdProcessingSlot takes one of proc's processing slots and returns the
|
|
||||||
// func that gives it back. Process takes its slot before it reads its input,
|
|
||||||
// so once it has read a byte from the pipe it holds the slot, until the pipe
|
|
||||||
// is closed.
|
|
||||||
func holdProcessingSlot(
|
|
||||||
t *testing.T, proc *imageprocessor.ImageProcessor,
|
|
||||||
) func() {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
input, feed := io.Pipe()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_, _ = proc.Process(t.Context(), input, &imageprocessor.Request{})
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err := feed.Write([]byte{0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process call to hold the slot did not start: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
release := func() { _ = feed.Close() }
|
|
||||||
t.Cleanup(release)
|
|
||||||
|
|
||||||
return release
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestService_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
|
||||||
// request whose source is cached holds none of it while it waits for a
|
|
||||||
// processing slot: it reads the cached file only once it has a slot. With
|
|
||||||
// the only slot held, a request for a new width of the cached 100x100 photo
|
|
||||||
// waits; the cached file is then rewritten as a 100x50 image before the slot
|
|
||||||
// is freed, so the request must answer with that image scaled to 40x20.
|
|
||||||
func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
|
||||||
svc.processor = imageprocessor.New(
|
|
||||||
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
|
||||||
)
|
|
||||||
|
|
||||||
// A first request caches the photo as a source.
|
|
||||||
resp, err := svc.Get(t.Context(), widthOnlyRequest(fixtures, 50))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Get() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
|
||||||
widthOnlyRequest(fixtures, 50))
|
|
||||||
if err != nil || contentHash == "" {
|
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
|
||||||
contentHash, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
release := holdProcessingSlot(t, svc.processor)
|
|
||||||
|
|
||||||
var (
|
|
||||||
waited *ImageResponse
|
|
||||||
waitedErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
|
|
||||||
waited, waitedErr = svc.Get(t.Context(), widthOnlyRequest(fixtures, 40))
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Give the request time to reach the slot: had it read the cached source
|
|
||||||
// before waiting, it would have read it by now.
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
err = os.WriteFile(svc.cache.srcContent.hashToPath(contentHash),
|
|
||||||
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to rewrite the cached source: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
<-done
|
|
||||||
|
|
||||||
if waitedErr != nil {
|
|
||||||
t.Fatalf("Get() error = %v", waitedErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = waited.Content.Close() }()
|
|
||||||
|
|
||||||
output, err := jpeg.DecodeConfig(waited.Content)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to decode the response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if output.Width != 40 || output.Height != 20 {
|
|
||||||
t.Errorf("response is %dx%d, want 40x20: the request read the cached "+
|
|
||||||
"source before it had a processing slot", output.Width, output.Height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// webpRequest returns a request for a 100x100 WebP variant of path.
|
|
||||||
func webpRequest(path string) *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: path,
|
|
||||||
Size: Size{Width: 100, Height: 100},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertVariantServed checks that GetVariant serves key with the given
|
|
||||||
// content and the image/webp content type storeEvictionTestVariant stores.
|
|
||||||
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, size, contentType, err := cache.GetVariant(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
got, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading variant %s: %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Equal(got, content) {
|
|
||||||
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if size != int64(len(content)) {
|
|
||||||
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType != testContentTypeWebP {
|
|
||||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
||||||
key, contentType, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertVariantNotFound checks that GetVariant refuses key with
|
|
||||||
// ErrNotFound.
|
|
||||||
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, _, _, err := cache.GetVariant(key)
|
|
||||||
if err == nil {
|
|
||||||
_ = reader.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertLookupMisses checks that Lookup reports request as a miss.
|
|
||||||
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lookup, err := cache.Lookup(t.Context(), request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lookup.Hit {
|
|
||||||
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renameFile renames the file at from to to.
|
|
||||||
func renameFile(t *testing.T, from, to string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
err := os.Rename(from, to)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("renaming %s: %v", from, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
|
||||||
// stored or read, a hit takes its content type from memory: with the
|
|
||||||
// .meta file deleted, GetVariant must still return the stored content
|
|
||||||
// type rather than the application/octet-stream it uses without one.
|
|
||||||
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
// A second Cache on the same state directory starts with nothing in
|
|
||||||
// memory, as pixad does after a restart, so its first read uses the
|
|
||||||
// .meta file.
|
|
||||||
restarted, err := NewCache(cache.db, cache.config)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewCache() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
|
|
||||||
// which began before StoreVariant finished cannot replace the content
|
|
||||||
// type the store kept in memory. Such a read can find the variant file
|
|
||||||
// but not yet its .meta file, and so gets application/octet-stream. The
|
|
||||||
// test deletes the .meta file after the store, then runs the part of
|
|
||||||
// GetVariant that comes after its check of memory.
|
|
||||||
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadOfOlderMetaFileKeepsStoredContentType checks that a read
|
|
||||||
// which got its content type from a .meta file that StoreVariant had
|
|
||||||
// not yet rewritten cannot replace the type the store kept in memory.
|
|
||||||
// The test writes such a .meta file, with a different content type,
|
|
||||||
// after the store, then runs the part of GetVariant that comes after
|
|
||||||
// its check of memory.
|
|
||||||
func TestReadOfOlderMetaFileKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
olderMeta, err := json.Marshal(VariantMeta{
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
Size: int64(len(content)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encoding .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metaPath := cache.variants.keyToPath(testVariantKeyOne) + ".meta"
|
|
||||||
|
|
||||||
err = os.WriteFile(metaPath, olderMeta, StorageFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if contentType != testContentTypeJPEG {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) content type = %q, want %q from the .meta file",
|
|
||||||
testVariantKeyOne, contentType, testContentTypeJPEG)
|
|
||||||
}
|
|
||||||
|
|
||||||
kept, _ := cache.metaCache.Get(testVariantKeyOne)
|
|
||||||
if kept != testContentTypeWebP {
|
|
||||||
t.Errorf("content type in memory = %q, want the stored %q",
|
|
||||||
kept, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedReadDuringStoreKeepsStoredContentType checks that a read
|
|
||||||
// which found no .meta file cannot leave application/octet-stream in
|
|
||||||
// memory, even when another read has removed the content type
|
|
||||||
// StoreVariant kept there. In this order: the store; a read that finds
|
|
||||||
// the variant in memory but cannot open its file, and so removes it
|
|
||||||
// from memory; a read that opened the variant file before the store
|
|
||||||
// wrote its .meta file. Later hits must get the stored content type.
|
|
||||||
func TestFailedReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
variantPath := cache.variants.keyToPath(testVariantKeyOne)
|
|
||||||
metaPath := variantPath + ".meta"
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
renameFile(t, variantPath, variantPath+".hidden")
|
|
||||||
assertVariantNotFound(t, cache, testVariantKeyOne)
|
|
||||||
renameFile(t, variantPath+".hidden", variantPath)
|
|
||||||
|
|
||||||
renameFile(t, metaPath, metaPath+".hidden")
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.GetVariant(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
||||||
|
|
||||||
renameFile(t, metaPath+".hidden", metaPath)
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
|
||||||
// removed is a miss and cannot be read, although it had been stored
|
|
||||||
// and served before.
|
|
||||||
func TestEvictedVariantIsNotServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1500)
|
|
||||||
|
|
||||||
oldRequest := webpRequest("/old.jpg")
|
|
||||||
newRequest := webpRequest("/new.jpg")
|
|
||||||
oldKey := CacheKey(oldRequest)
|
|
||||||
newKey := CacheKey(newRequest)
|
|
||||||
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
|
||||||
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
|
||||||
storeEvictionTestVariant(t, cache, newKey, newContent)
|
|
||||||
assertVariantServed(t, cache, oldKey, oldContent)
|
|
||||||
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertLookupMisses(t, cache, oldRequest)
|
|
||||||
assertVariantNotFound(t, cache, oldKey)
|
|
||||||
assertVariantServed(t, cache, newKey, newContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
|
||||||
// file was deleted by something other than the evictor cannot be read,
|
|
||||||
// and is a miss afterwards.
|
|
||||||
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
|
|
||||||
request := webpRequest("/deleted.jpg")
|
|
||||||
key := CacheKey(request)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, key, content)
|
|
||||||
assertVariantServed(t, cache, key, content)
|
|
||||||
|
|
||||||
err := os.Remove(cache.variants.keyToPath(key))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing variant file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantNotFound(t, cache, key)
|
|
||||||
assertLookupMisses(t, cache, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
|
||||||
// variants from several goroutines at once, for the race detector.
|
|
||||||
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for goroutine := range 8 {
|
|
||||||
wg.Go(func() {
|
|
||||||
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
|
||||||
content := []byte(key)
|
|
||||||
|
|
||||||
for range 20 {
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.GetVariant(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("GetVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if contentType != testContentTypeWebP {
|
|
||||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
||||||
key, contentType, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cache.evictVariant(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("evictVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantNotFound(t, cache, key)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
@@ -43,9 +43,6 @@ type ServiceConfig struct {
|
|||||||
SigningKey string
|
SigningKey string
|
||||||
// Allowlist is the list of hosts that don't require signatures
|
// Allowlist is the list of hosts that don't require signatures
|
||||||
Allowlist []string
|
Allowlist []string
|
||||||
// MaxConcurrentProcessing is the most images processed at once; zero
|
|
||||||
// uses the image processor's default, one per CPU
|
|
||||||
MaxConcurrentProcessing int
|
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -94,10 +91,9 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxResponseSize := fetcherCfg.MaxResponseSize
|
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||||
processor := imageprocessor.New(imageprocessor.Params{
|
processor := imageprocessor.New(
|
||||||
MaxInputBytes: maxResponseSize,
|
imageprocessor.Params{MaxInputBytes: maxResponseSize},
|
||||||
MaxConcurrentProcessing: cfg.MaxConcurrentProcessing,
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
@@ -147,8 +143,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err)
|
s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err)
|
||||||
// Fall through to re-process
|
// Fall through to re-process
|
||||||
} else {
|
} else {
|
||||||
// Counted also when the request context has ended meanwhile
|
s.cache.IncrementStats(ctx, true, 0)
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
|
|
||||||
|
|
||||||
return &ImageResponse{
|
return &ImageResponse{
|
||||||
Content: reader,
|
Content: reader,
|
||||||
@@ -160,15 +155,12 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss - process the cached source or fetch it, then count the
|
// Cache miss - check if we have source content cached
|
||||||
// miss with the bytes it fetched from upstream, also when it failed or
|
|
||||||
// the request context has ended meanwhile
|
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
s.cache.IncrementStats(ctx, false, 0)
|
||||||
|
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), false, fetchedBytes)
|
|
||||||
|
|
||||||
|
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -213,23 +205,12 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
|
|||||||
return s.signer.Verify(signatureRequest(req))
|
return s.signer.Verify(signatureRequest(req))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSignedURL generates a signed URL for the given request. The URL
|
// GenerateSignedURL generates a signed URL for the given request.
|
||||||
// carries q and fit next to sig and exp, so the image route verifies it for
|
|
||||||
// the quality and fit it was signed with. An unset quality or fit is first
|
|
||||||
// set to 85 or cover, the values the route uses when a URL has no q or fit.
|
|
||||||
func (s *Service) GenerateSignedURL(
|
func (s *Service) GenerateSignedURL(
|
||||||
baseURL string,
|
baseURL string,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
ttl time.Duration,
|
ttl time.Duration,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if req.Quality == 0 {
|
|
||||||
req.Quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode == "" {
|
|
||||||
req.FitMode = FitCover
|
|
||||||
}
|
|
||||||
|
|
||||||
sigReq := signatureRequest(req)
|
sigReq := signatureRequest(req)
|
||||||
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||||
|
|
||||||
@@ -237,51 +218,50 @@ func (s *Service) GenerateSignedURL(
|
|||||||
req.Expires = sigReq.Expires
|
req.Expires = sigReq.Expires
|
||||||
req.Signature = sigReq.Signature
|
req.Signature = sigReq.Signature
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s",
|
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
||||||
baseURL, path, sig, exp, req.Quality, req.FitMode), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCachedSource opens source content from cache, without reading it, and
|
// loadCachedSource attempts to load source content from cache, returning nil
|
||||||
// returns it with its size; nil if the cached data is unavailable, empty or
|
// if the cached data is unavailable or exceeds maxResponseSize.
|
||||||
// exceeds maxResponseSize.
|
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
||||||
func (s *Service) loadCachedSource(
|
reader, err := s.cache.GetSourceContent(contentHash)
|
||||||
contentHash ContentHash,
|
|
||||||
) (io.ReadCloser, int64) {
|
|
||||||
reader, size, err := s.cache.GetSourceContent(contentHash)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to load cached source, fetching", "error", err)
|
s.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
return nil, 0
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if size > s.maxResponseSize {
|
// Bound the read to maxResponseSize to prevent unbounded memory use
|
||||||
_ = reader.Close()
|
// from unexpectedly large cached files.
|
||||||
|
limited := io.LimitReader(reader, s.maxResponseSize+1)
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to read cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > s.maxResponseSize {
|
||||||
s.log.Warn("cached source exceeds max response size, discarding",
|
s.log.Warn("cached source exceeds max response size, discarding",
|
||||||
"hash", contentHash,
|
"hash", contentHash,
|
||||||
"max_bytes", s.maxResponseSize,
|
"max_bytes", s.maxResponseSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if size == 0 {
|
return data
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content
|
// processFromSourceOrFetch processes an image, using cached source content
|
||||||
// if available. It also returns the number of bytes fetched from upstream,
|
// if available.
|
||||||
// as fetchAndProcess does, or 0 when the cached source was used.
|
|
||||||
func (s *Service) processFromSourceOrFetch(
|
func (s *Service) processFromSourceOrFetch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
cacheKey VariantKey,
|
cacheKey VariantKey,
|
||||||
) (*ImageResponse, int64, error) {
|
) (*ImageResponse, error) {
|
||||||
// Check if we have cached source content
|
// Check if we have cached source content
|
||||||
contentHash, _, err := s.cache.LookupSource(ctx, req)
|
contentHash, _, err := s.cache.LookupSource(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,38 +269,37 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
source io.ReadCloser
|
sourceData []byte
|
||||||
sourceSize int64
|
fetchBytes int64
|
||||||
)
|
)
|
||||||
|
|
||||||
if contentHash != "" {
|
if contentHash != "" {
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
s.log.Debug("using cached source", "hash", contentHash)
|
||||||
source, sourceSize = s.loadCachedSource(contentHash)
|
sourceData = s.loadCachedSource(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from upstream if we don't have source data or it's empty
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
if source == nil {
|
if len(sourceData) == 0 {
|
||||||
return s.fetchAndProcess(ctx, req, cacheKey)
|
resp, err := s.fetchAndProcess(ctx, req, cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = source.Close() }()
|
// Process using cached source
|
||||||
|
fetchBytes = int64(len(sourceData))
|
||||||
|
|
||||||
// Process using cached source; nothing was fetched from upstream. The
|
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
||||||
// image processor reads the source only once it has a processing slot,
|
|
||||||
// so a request waiting for one holds none of it in memory.
|
|
||||||
resp, err := s.processAndStore(ctx, req, cacheKey, source, sourceSize)
|
|
||||||
|
|
||||||
return resp, 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||||
// It also returns the number of bytes read from upstream, including when
|
|
||||||
// reading the response or a later step fails.
|
|
||||||
func (s *Service) fetchAndProcess(
|
func (s *Service) fetchAndProcess(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
cacheKey VariantKey,
|
cacheKey VariantKey,
|
||||||
) (*ImageResponse, int64, error) {
|
) (*ImageResponse, error) {
|
||||||
// Fetch from upstream
|
// Fetch from upstream
|
||||||
sourceURL := req.SourceURL()
|
sourceURL := req.SourceURL()
|
||||||
|
|
||||||
@@ -331,32 +310,23 @@ func (s *Service) fetchAndProcess(
|
|||||||
// Store negative cache for certain errors
|
// Store negative cache for certain errors
|
||||||
if isNegativeCacheable(err) {
|
if isNegativeCacheable(err) {
|
||||||
statusCode := extractStatusCode(err)
|
statusCode := extractStatusCode(err)
|
||||||
|
_ = s.cache.StoreNegative(ctx, req, statusCode, err.Error())
|
||||||
storeErr := s.cache.StoreNegative(ctx, req, statusCode, err.Error())
|
|
||||||
if storeErr != nil {
|
|
||||||
s.log.Warn("failed to store negative cache entry",
|
|
||||||
"host", req.SourceHost, "path", req.SourcePath, "error", storeErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, 0, fmt.Errorf("upstream fetch failed: %w", err)
|
return nil, fmt.Errorf("upstream fetch failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closing the body frees the upstream connection. It is closed only
|
|
||||||
// after processing, so the fetcher's connection limit also bounds the
|
|
||||||
// fetched sources held in memory while their requests wait for a
|
|
||||||
// processing slot.
|
|
||||||
defer func() { _ = fetchResult.Content.Close() }()
|
defer func() { _ = fetchResult.Content.Close() }()
|
||||||
|
|
||||||
// Read and validate the source content
|
// Read and validate the source content
|
||||||
sourceData, err := io.ReadAll(fetchResult.Content)
|
sourceData, err := io.ReadAll(fetchResult.Content)
|
||||||
fetchBytes := int64(len(sourceData))
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fetchBytes, fmt.Errorf("failed to read upstream response: %w", err)
|
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate download bitrate
|
// Calculate download bitrate
|
||||||
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
var downloadRate string
|
var downloadRate string
|
||||||
|
|
||||||
if fetchResult.FetchDurationMs > 0 {
|
if fetchResult.FetchDurationMs > 0 {
|
||||||
@@ -381,7 +351,7 @@ func (s *Service) fetchAndProcess(
|
|||||||
// Validate magic bytes match content type
|
// Validate magic bytes match content type
|
||||||
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fetchBytes, fmt.Errorf("content validation failed: %w", err)
|
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
@@ -391,20 +361,15 @@ func (s *Service) fetchAndProcess(
|
|||||||
// Continue even if caching fails
|
// Continue even if caching fails
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.processAndStore(
|
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
||||||
ctx, req, cacheKey, bytes.NewReader(sourceData), fetchBytes,
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp, fetchBytes, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndStore processes the image read from source and stores the
|
// processAndStore processes an image and stores the result.
|
||||||
// result.
|
|
||||||
func (s *Service) processAndStore(
|
func (s *Service) processAndStore(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
cacheKey VariantKey,
|
cacheKey VariantKey,
|
||||||
source io.Reader,
|
sourceData []byte,
|
||||||
fetchBytes int64,
|
fetchBytes int64,
|
||||||
) (*ImageResponse, error) {
|
) (*ImageResponse, error) {
|
||||||
// Process the image
|
// Process the image
|
||||||
@@ -417,16 +382,13 @@ func (s *Service) processAndStore(
|
|||||||
FitMode: imageprocessor.FitMode(req.FitMode),
|
FitMode: imageprocessor.FitMode(req.FitMode),
|
||||||
}
|
}
|
||||||
|
|
||||||
processResult, err := s.processor.Process(ctx, source, processReq)
|
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
processDuration := time.Since(processStart)
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
|
||||||
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
|
|
||||||
|
|
||||||
// Read processed content
|
// Read processed content
|
||||||
processedData, err := io.ReadAll(processResult.Content)
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
_ = processResult.Content.Close()
|
_ = processResult.Content.Close()
|
||||||
@@ -490,8 +452,6 @@ func signatureRequest(req *ImageRequest) *signature.Request {
|
|||||||
Width: req.Size.Width,
|
Width: req.Size.Width,
|
||||||
Height: req.Size.Height,
|
Height: req.Size.Height,
|
||||||
Format: string(req.Format),
|
Format: string(req.Format),
|
||||||
Quality: req.Quality,
|
|
||||||
FitMode: string(req.FitMode),
|
|
||||||
Signature: req.Signature,
|
Signature: req.Signature,
|
||||||
Expires: req.Expires,
|
Expires: req.Expires,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"image/color"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupStatsTestDB(t *testing.T) *sql.DB {
|
func setupStatsTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -108,392 +101,3 @@ func TestStats_ZeroCounts(t *testing.T) {
|
|||||||
t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate)
|
t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStats_LogsFailedCountQueries verifies that a failed item count query
|
|
||||||
// and a failed size query are each logged at warn and Stats still succeeds.
|
|
||||||
func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := setupStatsTestDB(t)
|
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: t.TempDir(),
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(),
|
|
||||||
`DROP TABLE source_content; DROP TABLE variant_content`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = cache.Stats(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stats() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, msg := range []string{
|
|
||||||
"failed to count cache items for stats",
|
|
||||||
"failed to sum cache size for stats",
|
|
||||||
} {
|
|
||||||
want := `"level":"WARN","msg":"` + msg + `"`
|
|
||||||
if !strings.Contains(logBuf.String(), want) {
|
|
||||||
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIncrementStats_LogsFailedUpdates verifies that a failed hit or miss
|
|
||||||
// count update and a failed upstream fetch count update are each logged at
|
|
||||||
// warn.
|
|
||||||
func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := setupStatsTestDB(t)
|
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: t.TempDir(),
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(), `DROP TABLE cache_stats`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.IncrementStats(t.Context(), false, 1024)
|
|
||||||
|
|
||||||
for _, msg := range []string{
|
|
||||||
"failed to count cache hit or miss",
|
|
||||||
"failed to count upstream fetch",
|
|
||||||
} {
|
|
||||||
want := `"level":"WARN","msg":"` + msg + `"`
|
|
||||||
if !strings.Contains(logBuf.String(), want) {
|
|
||||||
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
|
|
||||||
// TotalSizeBytes cover the stored source images and processed variants.
|
|
||||||
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
storeEvictionTestSource(t, cache, testHostCDN, testPathCat,
|
|
||||||
bytes.Repeat([]byte{0xAA}, 1000))
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
|
||||||
bytes.Repeat([]byte{0xAB}, 500))
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
|
||||||
bytes.Repeat([]byte{0xAC}, 250))
|
|
||||||
|
|
||||||
stats, err := cache.Stats(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stats() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats.TotalItems != 3 {
|
|
||||||
t.Errorf("TotalItems = %d, want 3 (1 source, 2 variants)", stats.TotalItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats.TotalSizeBytes != 1750 {
|
|
||||||
t.Errorf("TotalSizeBytes = %d, want 1750 (1000+500+250)",
|
|
||||||
stats.TotalSizeBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStats_DisabledCacheReportsNoItems verifies that a disabled disk cache
|
|
||||||
// reports no items and no size, even when its database still holds the
|
|
||||||
// rows of an earlier run with the disk cache enabled.
|
|
||||||
func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
enabled, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
storeEvictionTestSource(t, enabled, testHostCDN, testPathCat,
|
|
||||||
bytes.Repeat([]byte{0xAA}, 1000))
|
|
||||||
storeEvictionTestVariant(t, enabled, testVariantKeyOne,
|
|
||||||
bytes.Repeat([]byte{0xAB}, 500))
|
|
||||||
|
|
||||||
disabled, err := NewCache(enabled.db, CacheConfig{
|
|
||||||
StateDir: t.TempDir(),
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
DisableDiskCache: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := disabled.Stats(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stats() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats.TotalItems != 0 || stats.TotalSizeBytes != 0 {
|
|
||||||
t.Errorf("TotalItems = %d, TotalSizeBytes = %d, want 0 and 0",
|
|
||||||
stats.TotalItems, stats.TotalSizeBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheStatsCounters holds the counters of the cache_stats row, in column
|
|
||||||
// order.
|
|
||||||
type cacheStatsCounters struct {
|
|
||||||
hitCount int64
|
|
||||||
missCount int64
|
|
||||||
upstreamFetchCount int64
|
|
||||||
upstreamFetchBytes int64
|
|
||||||
transformCount int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCacheStatsCounters reads the counters of the cache_stats row.
|
|
||||||
func readCacheStatsCounters(t *testing.T, cache *Cache) cacheStatsCounters {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var got cacheStatsCounters
|
|
||||||
|
|
||||||
err := cache.db.QueryRowContext(t.Context(), `
|
|
||||||
SELECT hit_count, miss_count, upstream_fetch_count,
|
|
||||||
upstream_fetch_bytes, transform_count
|
|
||||||
FROM cache_stats WHERE id = 1
|
|
||||||
`).Scan(&got.hitCount, &got.missCount, &got.upstreamFetchCount,
|
|
||||||
&got.upstreamFetchBytes, &got.transformCount)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read cache_stats: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestService_Get_CountsStats walks Get through a miss that fetches the
|
|
||||||
// source, a hit, a miss that reuses the cached source, and two misses whose
|
|
||||||
// source cannot be used, checking every cache_stats counter after each.
|
|
||||||
func TestService_Get_CountsStats(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
|
||||||
|
|
||||||
// NewTestFS builds the same files the test service's fetcher serves.
|
|
||||||
testFS, _ := NewTestFS(t)
|
|
||||||
|
|
||||||
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
|
|
||||||
|
|
||||||
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
|
||||||
steps := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
size int
|
|
||||||
wantErr bool
|
|
||||||
want cacheStatsCounters
|
|
||||||
}{
|
|
||||||
{"miss that fetches the source", testPathPhoto, 50, false,
|
|
||||||
cacheStatsCounters{0, 1, 1, photoBytes, 1}},
|
|
||||||
{"hit", testPathPhoto, 50, false,
|
|
||||||
cacheStatsCounters{1, 1, 1, photoBytes, 1}},
|
|
||||||
{"miss that reuses the cached source", testPathPhoto, 25, false,
|
|
||||||
cacheStatsCounters{1, 2, 1, photoBytes, 2}},
|
|
||||||
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
|
|
||||||
cacheStatsCounters{1, 3, 2, photoBytes + fakeBytes, 2}},
|
|
||||||
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
|
|
||||||
cacheStatsCounters{1, 4, 2, photoBytes + fakeBytes, 2}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, step := range steps {
|
|
||||||
resp, err := svc.Get(t.Context(), &ImageRequest{
|
|
||||||
SourceHost: fixtures.GoodHost,
|
|
||||||
SourcePath: step.path,
|
|
||||||
Size: Size{Width: step.size, Height: step.size},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
})
|
|
||||||
if (err != nil) != step.wantErr {
|
|
||||||
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
got := readCacheStatsCounters(t, svc.cache)
|
|
||||||
if got != step.want {
|
|
||||||
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeUpstream answers every fetch with itself as a JPEG body. The body
|
|
||||||
// serves data, then calls cancel, when set, and returns err; io.EOF ends
|
|
||||||
// the body normally.
|
|
||||||
type fakeUpstream struct {
|
|
||||||
data *bytes.Reader
|
|
||||||
cancel context.CancelFunc
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *fakeUpstream) Fetch(
|
|
||||||
context.Context, string,
|
|
||||||
) (*httpfetcher.FetchResult, error) {
|
|
||||||
return &httpfetcher.FetchResult{
|
|
||||||
Content: io.NopCloser(u),
|
|
||||||
ContentLength: -1,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *fakeUpstream) Read(p []byte) (int, error) {
|
|
||||||
if u.data.Len() > 0 {
|
|
||||||
return u.data.Read(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.cancel != nil {
|
|
||||||
u.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, u.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestService_Get_CountsInterruptedMisses checks every cache_stats counter
|
|
||||||
// after a miss whose request context ends during or after the upstream
|
|
||||||
// fetch, and after a miss whose upstream body is over the size limit.
|
|
||||||
func TestService_Get_CountsInterruptedMisses(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
|
||||||
half := len(photo) / 2
|
|
||||||
|
|
||||||
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
served int // bytes of the photo the upstream body serves
|
|
||||||
cancel bool // whether the body then ends the request context
|
|
||||||
readErr error // what the body then returns
|
|
||||||
wantErr bool
|
|
||||||
want cacheStatsCounters
|
|
||||||
}{
|
|
||||||
{"request context ends during the fetch", half, true, context.Canceled, true,
|
|
||||||
cacheStatsCounters{0, 1, 1, int64(half), 0}},
|
|
||||||
{"request context ends after the fetch", len(photo), true, io.EOF, false,
|
|
||||||
cacheStatsCounters{0, 1, 1, int64(len(photo)), 1}},
|
|
||||||
{"upstream body over the size limit", half, false,
|
|
||||||
httpfetcher.ErrResponseTooLarge, true,
|
|
||||||
cacheStatsCounters{0, 1, 1, int64(half), 0}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
upstream := &fakeUpstream{
|
|
||||||
data: bytes.NewReader(photo[:tc.served]),
|
|
||||||
err: tc.readErr,
|
|
||||||
}
|
|
||||||
if tc.cancel {
|
|
||||||
upstream.cancel = cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.fetcher = upstream
|
|
||||||
|
|
||||||
resp, err := svc.Get(ctx, &ImageRequest{
|
|
||||||
SourceHost: fixtures.GoodHost,
|
|
||||||
SourcePath: testPathPhoto,
|
|
||||||
Size: Size{Width: 50, Height: 50},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
})
|
|
||||||
t.Logf("Get() error = %v", err)
|
|
||||||
|
|
||||||
if (err != nil) != tc.wantErr {
|
|
||||||
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
got := readCacheStatsCounters(t, svc.cache)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("counters = %+v, want %+v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestService_Get_CountsHitAfterRequestEnds checks every cache_stats counter
|
|
||||||
// after a hit served with a request context that has already ended: only
|
|
||||||
// the hit count moves.
|
|
||||||
func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: fixtures.GoodHost,
|
|
||||||
SourcePath: testPathPhoto,
|
|
||||||
Size: Size{Width: 50, Height: 50},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
// A first request caches the variant.
|
|
||||||
resp, err := svc.Get(t.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Get() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
|
|
||||||
want := readCacheStatsCounters(t, svc.cache)
|
|
||||||
want.hitCount++
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
resp, err = svc.Get(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Get() with an ended request context: error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
|
|
||||||
if resp.CacheStatus != CacheHit {
|
|
||||||
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := readCacheStatsCounters(t, svc.cache)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -393,7 +392,6 @@ func CacheKey(req *ImageRequest) VariantKey {
|
|||||||
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
||||||
type VariantStorage struct {
|
type VariantStorage struct {
|
||||||
baseDir string
|
baseDir string
|
||||||
log *slog.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VariantMeta contains metadata about a cached variant.
|
// VariantMeta contains metadata about a cached variant.
|
||||||
@@ -406,14 +404,13 @@ type VariantMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewVariantStorage creates a new variant storage at the given base directory.
|
// NewVariantStorage creates a new variant storage at the given base directory.
|
||||||
// A failed .meta write is logged to log.
|
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
||||||
func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) {
|
|
||||||
err := os.MkdirAll(baseDir, StorageDirPerm)
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
return &VariantStorage{baseDir: baseDir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content and metadata to storage at the given key.
|
// Store writes content and metadata to storage at the given key.
|
||||||
@@ -481,11 +478,7 @@ func (s *VariantStorage) Store(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Metadata write failure is non-fatal; content is already stored.
|
// Metadata write failure is non-fatal; content is already stored.
|
||||||
err = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
_ = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to write variant metadata sidecar",
|
|
||||||
"path", metaPath, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
@@ -506,44 +499,32 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithSize returns a reader and file size for the content at the
|
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||||
// given key.
|
// the given key.
|
||||||
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
|
func (s *VariantStorage) LoadWithMeta(
|
||||||
|
key VariantKey,
|
||||||
|
) (io.ReadCloser, int64, string, error) {
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
|
metaPath := path + ".meta"
|
||||||
|
|
||||||
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, 0, fmt.Errorf("failed to open content: %w", err)
|
return nil, 0, "", fmt.Errorf("failed to open content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stat, err := f.Stat()
|
stat, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
|
|
||||||
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
|
return nil, 0, "", fmt.Errorf("failed to stat content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, stat.Size(), nil
|
// Load metadata for content type
|
||||||
}
|
contentType := "application/octet-stream" // fallback
|
||||||
|
|
||||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
|
||||||
// the given key. The content type is read from the .meta file, and is
|
|
||||||
// empty when that file is missing or unreadable.
|
|
||||||
func (s *VariantStorage) LoadWithMeta(
|
|
||||||
key VariantKey,
|
|
||||||
) (io.ReadCloser, int64, string, error) {
|
|
||||||
f, size, err := s.LoadWithSize(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentType string
|
|
||||||
|
|
||||||
metaPath := s.keyToPath(key) + ".meta"
|
|
||||||
|
|
||||||
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -553,7 +534,7 @@ func (s *VariantStorage) LoadWithMeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, size, contentType, nil
|
return f, stat.Size(), contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exists checks if content exists at the given key.
|
// Exists checks if content exists at the given key.
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -406,35 +404,3 @@ func TestCacheKey(t *testing.T) {
|
|||||||
t.Error("CacheKey() produced same key for different quality")
|
t.Error("CacheKey() produced same key for different quality")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
|
|
||||||
// that fails is logged at warn and the store still succeeds.
|
|
||||||
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
|
|
||||||
storage, err := NewVariantStorage(
|
|
||||||
t.TempDir(), slog.New(slog.NewJSONHandler(&logBuf, nil)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewVariantStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key := CacheKey(&ImageRequest{SourceHost: testHostCDN, SourcePath: testPathCat})
|
|
||||||
|
|
||||||
// A directory where the .meta file goes makes the .meta write fail.
|
|
||||||
err = os.MkdirAll(storage.keyToPath(key)+".meta", StorageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := `"level":"WARN","msg":"failed to write variant metadata sidecar"`
|
|
||||||
if !strings.Contains(logBuf.String(), want) {
|
|
||||||
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const (
|
|||||||
testHostExample = "example.com"
|
testHostExample = "example.com"
|
||||||
testPathCat = "/photos/cat.jpg"
|
testPathCat = "/photos/cat.jpg"
|
||||||
testContentTypeJPEG = "image/jpeg"
|
testContentTypeJPEG = "image/jpeg"
|
||||||
testContentTypeWebP = "image/webp"
|
|
||||||
testHeaderContentType = "Content-Type"
|
testHeaderContentType = "Content-Type"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,21 +23,6 @@ var (
|
|||||||
// MaxDimension is the maximum allowed width or height.
|
// MaxDimension is the maximum allowed width or height.
|
||||||
const MaxDimension = 8192
|
const MaxDimension = 8192
|
||||||
|
|
||||||
// ValidateDimension checks one requested width or height; name ("width" or
|
|
||||||
// "height") appears in the error. 0 means "original size" and is valid.
|
|
||||||
func ValidateDimension(name string, value int) error {
|
|
||||||
if value < 0 {
|
|
||||||
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if value > MaxDimension {
|
|
||||||
return fmt.Errorf("%w: %s is above %d",
|
|
||||||
ErrDimensionTooLarge, name, MaxDimension)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
||||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
||||||
|
|
||||||
@@ -240,20 +225,14 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if width > MaxDimension || height > MaxDimension {
|
||||||
|
return Size{}, "", ErrDimensionTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
size = Size{Width: width, Height: height}
|
size = Size{Width: width, Height: height}
|
||||||
formatStr = matches[3]
|
formatStr = matches[3]
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ValidateDimension("width", size.Width)
|
|
||||||
if err != nil {
|
|
||||||
return Size{}, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ValidateDimension("height", size.Height)
|
|
||||||
if err != nil {
|
|
||||||
return Size{}, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
format, err := parseFormat(formatStr)
|
format, err := parseFormat(formatStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateImageRequest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
req ImageRequest
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "within bounds",
|
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "original size and empty fit",
|
|
||||||
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "width over limit",
|
|
||||||
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
|
||||||
wantErr: ErrDimensionTooLarge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "height over limit",
|
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
|
||||||
wantErr: ErrDimensionTooLarge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative width",
|
|
||||||
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
|
||||||
wantErr: ErrInvalidSize,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid fit mode",
|
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
|
||||||
wantErr: ErrInvalidFitMode,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := ValidateImageRequest(&tt.req)
|
|
||||||
if tt.wantErr == nil {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, tt.wantErr) {
|
|
||||||
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,11 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/go-chi/httprate"
|
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
@@ -90,31 +88,6 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimit returns a middleware that limits each client to requestLimit
|
|
||||||
// requests per window and refuses a request over the limit with 429 Too Many
|
|
||||||
// Requests and a Retry-After header. Clients are told apart by the address
|
|
||||||
// the ClientIP middleware stored in the request context, so ClientIP must
|
|
||||||
// run first. An IPv6 client is counted by its /64, which one client usually
|
|
||||||
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
|
||||||
// IPv4 address it carries, since every such address falls in the same /64.
|
|
||||||
// Counts are kept only for the current and the previous window, so memory
|
|
||||||
// stays bounded.
|
|
||||||
func (s *Middleware) RateLimit(
|
|
||||||
requestLimit int, window time.Duration,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
return httprate.LimitBy(requestLimit, window,
|
|
||||||
func(r *http.Request) (string, error) {
|
|
||||||
ip := clientip.FromContext(r.Context())
|
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(ip)
|
|
||||||
if err == nil {
|
|
||||||
ip = addr.Unmap().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return httprate.CanonicalizeIP(ip), nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
|
|
||||||
@@ -172,7 +145,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
// CORS returns a CORS middleware.
|
// CORS returns a CORS middleware.
|
||||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{s.config.AccessControlAllowOrigin},
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
|
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
|
|||||||
@@ -9,53 +9,6 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCORSAnswersWithConfiguredOrigin checks that the CORS middleware
|
|
||||||
// answers with access_control_allow_origin, where "*" lets any origin read
|
|
||||||
// responses and a single origin lets only that origin read them.
|
|
||||||
func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const appOrigin = "https://app.example.com"
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
configured string
|
|
||||||
requestOrigin string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"*", "https://any.example.com", "*"},
|
|
||||||
{appOrigin, appOrigin, appOrigin},
|
|
||||||
{appOrigin, "https://other.example.com", ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
mw := &Middleware{
|
|
||||||
log: slog.Default(),
|
|
||||||
config: &config.Config{AccessControlAllowOrigin: tc.configured},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := mw.CORS()(testHandler)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/v1/image/example.com/a.jpg/1x1.png", nil)
|
|
||||||
req.Header.Set("Origin", tc.requestOrigin)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("configured %q, request from %q: "+
|
|
||||||
"Access-Control-Allow-Origin = %q, want %q",
|
|
||||||
tc.configured, tc.requestOrigin, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityHeaders(t *testing.T) {
|
func TestSecurityHeaders(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
// short, so a slowloris client dribbling headers is dropped well
|
// short, so a slowloris client dribbling headers is dropped well
|
||||||
// before it ties up a connection for the whole ReadTimeout window.
|
// before it ties up a connection for the whole ReadTimeout window.
|
||||||
HTTPReadHeaderTimeout = 10 * time.Second
|
HTTPReadHeaderTimeout = 10 * time.Second
|
||||||
|
HTTPWriteTimeout = 60 * time.Second
|
||||||
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
|
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
|
||||||
// held open, so idle connections cannot accumulate without limit on a
|
// held open, so idle connections cannot accumulate without limit on a
|
||||||
// service targeting high concurrency.
|
// service targeting high concurrency.
|
||||||
@@ -29,7 +30,7 @@ func (s *Server) newHTTPServer() *http.Server {
|
|||||||
Addr: fmt.Sprintf(":%d", s.config.Port),
|
Addr: fmt.Sprintf(":%d", s.config.Port),
|
||||||
ReadTimeout: HTTPReadTimeout,
|
ReadTimeout: HTTPReadTimeout,
|
||||||
ReadHeaderTimeout: HTTPReadHeaderTimeout,
|
ReadHeaderTimeout: HTTPReadHeaderTimeout,
|
||||||
WriteTimeout: s.config.DownstreamTimeout,
|
WriteTimeout: HTTPWriteTimeout,
|
||||||
IdleTimeout: HTTPIdleTimeout,
|
IdleTimeout: HTTPIdleTimeout,
|
||||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
|
|||||||
@@ -11,15 +11,11 @@ import (
|
|||||||
// carries every hardening timeout wired onto it, including the slowloris
|
// carries every hardening timeout wired onto it, including the slowloris
|
||||||
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
|
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
|
||||||
// guards against a field being defined but never set on the server, so
|
// guards against a field being defined but never set on the server, so
|
||||||
// each assertion compares the server field to its constant, or, for
|
// each assertion compares the server field to its constant.
|
||||||
// WriteTimeout, to downstream_timeout from the config.
|
|
||||||
func TestNewHTTPServerTimeouts(t *testing.T) {
|
func TestNewHTTPServerTimeouts(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := &Server{config: &config.Config{
|
s := &Server{config: &config.Config{Port: 8080}}
|
||||||
Port: 8080,
|
|
||||||
DownstreamTimeout: 45 * time.Second,
|
|
||||||
}}
|
|
||||||
|
|
||||||
srv := s.newHTTPServer()
|
srv := s.newHTTPServer()
|
||||||
|
|
||||||
@@ -30,7 +26,7 @@ func TestNewHTTPServerTimeouts(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
|
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
|
||||||
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
|
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
|
||||||
{"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
|
{"WriteTimeout", srv.WriteTimeout, HTTPWriteTimeout},
|
||||||
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
|
||||||
"sneak.berlin/go/pixa/internal/handlers"
|
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
|
||||||
"sneak.berlin/go/pixa/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testSigningKey is a throwaway signing key; submitting it logs in.
|
|
||||||
const testSigningKey = "test-signing-key-0123456789abcdef"
|
|
||||||
|
|
||||||
// wrongKey is submitted for a failed login.
|
|
||||||
const wrongKey = "not-the-signing-key"
|
|
||||||
|
|
||||||
// Addresses for the login rate limit tests. The test server trusts
|
|
||||||
// 10.0.0.0/8 as its proxies, so the X-Forwarded-For sent by proxyPeer is
|
|
||||||
// believed and the one sent by firstClient or secondClient is ignored.
|
|
||||||
const (
|
|
||||||
firstClient = "198.51.100.1:40000"
|
|
||||||
secondClient = "198.51.100.2:40000"
|
|
||||||
proxyPeer = "10.0.0.1:40000"
|
|
||||||
firstForwarded = "203.0.113.1"
|
|
||||||
secondForwarded = "203.0.113.2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// csrfFieldPattern extracts the CSRF token rendered into the login form.
|
|
||||||
var csrfFieldPattern = regexp.MustCompile(
|
|
||||||
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
|
||||||
|
|
||||||
// newTestServer builds the server's real routes from the constructors
|
|
||||||
// cmd/pixad uses, with a throwaway state directory. Debug marks requests
|
|
||||||
// as plain HTTP, so the CSRF check runs without an https Referer.
|
|
||||||
func newTestServer(t *testing.T) *Server {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
cfg := &config.Config{
|
|
||||||
Debug: true,
|
|
||||||
SigningKey: testSigningKey,
|
|
||||||
StateDir: stateDir,
|
|
||||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
|
||||||
DownstreamTimeout: config.DefaultDownstreamTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("database.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hc, err := healthcheck.New(lc, healthcheck.Params{
|
|
||||||
Globals: &globals.Globals{}, Config: cfg, Logger: log, Database: db,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("healthcheck.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h, err := handlers.New(lc, handlers.Params{
|
|
||||||
Logger: log, Healthcheck: hc, Database: db, Config: cfg,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("handlers.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mw, err := middleware.New(lc, middleware.Params{Logger: log, Config: cfg})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("middleware.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lc.RequireStart()
|
|
||||||
t.Cleanup(lc.RequireStop)
|
|
||||||
|
|
||||||
s := &Server{config: cfg, mw: mw, h: h}
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientRequest builds a request for / arriving from remoteAddr, carrying
|
|
||||||
// forwardedFor as its X-Forwarded-For header when that is not empty.
|
|
||||||
func clientRequest(
|
|
||||||
t *testing.T, method string, body io.Reader, remoteAddr, forwardedFor string,
|
|
||||||
) *http.Request {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
|
||||||
req.RemoteAddr = remoteAddr
|
|
||||||
|
|
||||||
if forwardedFor != "" {
|
|
||||||
req.Header.Set("X-Forwarded-For", forwardedFor)
|
|
||||||
}
|
|
||||||
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
// postLogin loads the login form with GET / and submits key in it with
|
|
||||||
// POST /, as a browser does, both from the same client. GET / is not rate
|
|
||||||
// limited, so the form must load even for a client over the limit.
|
|
||||||
func postLogin(
|
|
||||||
t *testing.T, s *Server, remoteAddr, forwardedFor, key string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
page := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(page,
|
|
||||||
clientRequest(t, http.MethodGet, nil, remoteAddr, forwardedFor))
|
|
||||||
|
|
||||||
if page.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET / status = %d, want %d", page.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
match := csrfFieldPattern.FindStringSubmatch(page.Body.String())
|
|
||||||
if match == nil {
|
|
||||||
t.Fatalf("no CSRF token field found in the login form")
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{"key": {key}, "gorilla.csrf.Token": {match[1]}}
|
|
||||||
req := clientRequest(t, http.MethodPost,
|
|
||||||
strings.NewReader(form.Encode()), remoteAddr, forwardedFor)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
for _, c := range page.Result().Cookies() {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripLoginRateLimit makes LoginAttemptsPerMinute failed logins from one
|
|
||||||
// client, each answered with the login form again, then one more, which
|
|
||||||
// must be refused with 429. It returns the response to that last attempt.
|
|
||||||
func tripLoginRateLimit(
|
|
||||||
t *testing.T, s *Server, remoteAddr, forwardedFor string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for attempt := range LoginAttemptsPerMinute {
|
|
||||||
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("failed login %d status = %d, want %d",
|
|
||||||
attempt+1, rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
|
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("login over the limit status = %d, want %d",
|
|
||||||
rec.Code, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginRateLimitRefusesAttemptOverLimit verifies the login attempt
|
|
||||||
// after LoginAttemptsPerMinute failed ones from one client is refused with
|
|
||||||
// 429 and a Retry-After header, and that the client cannot get around the
|
|
||||||
// limit by sending X-Forwarded-For: from a peer that is not a trusted
|
|
||||||
// proxy, the header is ignored.
|
|
||||||
func TestLoginRateLimitRefusesAttemptOverLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
|
|
||||||
rec := tripLoginRateLimit(t, s, firstClient, "")
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = postLogin(t, s, firstClient, secondForwarded, wrongKey)
|
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
|
||||||
t.Errorf("login with X-Forwarded-For from an untrusted peer "+
|
|
||||||
"status = %d, want %d", rec.Code, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginRateLimitLeavesOtherClientsAlone verifies one client going over
|
|
||||||
// the limit does not limit another: a failed login from a different
|
|
||||||
// address is answered with the login form, and the signing key still logs
|
|
||||||
// it in.
|
|
||||||
func TestLoginRateLimitLeavesOtherClientsAlone(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
|
|
||||||
tripLoginRateLimit(t, s, firstClient, "")
|
|
||||||
|
|
||||||
rec := postLogin(t, s, secondClient, "", wrongKey)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("failed login from another client status = %d, want %d",
|
|
||||||
rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = postLogin(t, s, secondClient, "", testSigningKey)
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Errorf("login with the signing key from another client "+
|
|
||||||
"status = %d, want %d", rec.Code, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginRateLimitCountsClientsBehindProxySeparately verifies the limit
|
|
||||||
// counts the client address resolved from X-Forwarded-For, not the address
|
|
||||||
// of the trusted proxy the requests arrive from, so two clients behind the
|
|
||||||
// same proxy are counted separately.
|
|
||||||
func TestLoginRateLimitCountsClientsBehindProxySeparately(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
|
|
||||||
tripLoginRateLimit(t, s, proxyPeer, firstForwarded)
|
|
||||||
|
|
||||||
rec := postLogin(t, s, proxyPeer, secondForwarded, wrongKey)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("failed login from a second client behind the proxy "+
|
|
||||||
"status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginRateLimitCountsIPv6ClientsByPrefix verifies an IPv6 client is
|
|
||||||
// counted by its /64: another address in the same /64 is refused too,
|
|
||||||
// while an address in a different /64 is not.
|
|
||||||
func TestLoginRateLimitCountsIPv6ClientsByPrefix(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
|
|
||||||
tripLoginRateLimit(t, s, proxyPeer, "2001:db8::1")
|
|
||||||
|
|
||||||
rec := postLogin(t, s, proxyPeer, "2001:db8::2", wrongKey)
|
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
|
||||||
t.Errorf("login from the same /64 status = %d, want %d",
|
|
||||||
rec.Code, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = postLogin(t, s, proxyPeer, "2001:db8:0:1::1", wrongKey)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("login from another /64 status = %d, want %d",
|
|
||||||
rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginRateLimitCountsIPv4MappedClientsSeparately verifies an IPv4
|
|
||||||
// client that the proxy forwards in IPv4-mapped IPv6 form (::ffff:a.b.c.d)
|
|
||||||
// is counted by its IPv4 address, not by the /64 that every such address
|
|
||||||
// shares, so two of them behind the proxy are counted separately.
|
|
||||||
func TestLoginRateLimitCountsIPv4MappedClientsSeparately(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
|
|
||||||
tripLoginRateLimit(t, s, proxyPeer, "::ffff:"+firstForwarded)
|
|
||||||
|
|
||||||
rec := postLogin(t, s, proxyPeer, "::ffff:"+secondForwarded, wrongKey)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("failed login from a second IPv4-mapped client "+
|
|
||||||
"status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
||||||
)
|
|
||||||
|
|
||||||
// unsignedImagePath is an image URL that carries no signature.
|
|
||||||
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
|
|
||||||
|
|
||||||
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
|
|
||||||
// mode is on, both image routes answer 503 Service Unavailable with a
|
|
||||||
// Retry-After header and the JSON error body the image handlers send.
|
|
||||||
func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.MaintenanceMode = true
|
|
||||||
|
|
||||||
requests := []struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
}{
|
|
||||||
{http.MethodGet, unsignedImagePath},
|
|
||||||
{http.MethodHead, unsignedImagePath},
|
|
||||||
{http.MethodGet, "/v1/e/token/cat.jpg"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range requests {
|
|
||||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), tc.method, tc.path, nil))
|
|
||||||
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want %d",
|
|
||||||
rec.Code, http.StatusServiceUnavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A HEAD response carries no body.
|
|
||||||
if tc.method == http.MethodHead {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
Timestamp string `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.NewDecoder(rec.Body).Decode(&body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("body is not JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.Error == "" || body.Status != http.StatusServiceUnavailable ||
|
|
||||||
body.Timestamp == "" {
|
|
||||||
t.Errorf("body = %+v, want an error, status %d and a timestamp",
|
|
||||||
body, http.StatusServiceUnavailable)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestsServedWithoutMaintenanceMode verifies that while
|
|
||||||
// maintenance mode is off, image requests reach the image handlers instead
|
|
||||||
// of the 503. The handlers refuse an unsigned image URL with 401 and a token
|
|
||||||
// they cannot decrypt with 400, so either status shows a request got through.
|
|
||||||
func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.MaintenanceMode = false
|
|
||||||
|
|
||||||
requests := []struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
|
||||||
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
|
||||||
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range requests {
|
|
||||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), tc.method, tc.path, nil))
|
|
||||||
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != tc.want {
|
|
||||||
t.Errorf("status = %d, want %d from the image handler",
|
|
||||||
rec.Code, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaintenanceModeKeepsOtherRoutes verifies that while maintenance mode
|
|
||||||
// is on, the health check still answers 200 and reports it, and the login
|
|
||||||
// page and /metrics still answer 200. The image's Docker HEALTHCHECK
|
|
||||||
// requests the health check: a 503 there would make the container
|
|
||||||
// unhealthy, and upaas marks a deploy failed when its container is
|
|
||||||
// unhealthy.
|
|
||||||
func TestMaintenanceModeKeepsOtherRoutes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.MaintenanceMode = true
|
|
||||||
|
|
||||||
// /metrics is routed only when its username is set.
|
|
||||||
s.config.MetricsUsername = "metrics"
|
|
||||||
s.config.MetricsPassword = "metrics-password"
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
|
|
||||||
http.MethodGet, "/.well-known/healthcheck.json", nil))
|
|
||||||
t.Logf("health check status %d, body %s", rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("health check status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
var health healthcheck.Response
|
|
||||||
|
|
||||||
err := json.NewDecoder(rec.Body).Decode(&health)
|
|
||||||
if err != nil || !health.Maintenance {
|
|
||||||
t.Errorf("health check maintenance_mode = %v (error %v), want true",
|
|
||||||
health.Maintenance, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, clientRequest(t, http.MethodGet, nil, firstClient, ""))
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("login page status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(),
|
|
||||||
http.MethodGet, "/metrics", nil)
|
|
||||||
req.SetBasicAuth(s.config.MetricsUsername, s.config.MetricsPassword)
|
|
||||||
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("/metrics status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-59
@@ -1,10 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -15,14 +12,6 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/static"
|
"sneak.berlin/go/pixa/internal/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoginAttemptsPerMinute is how many login attempts (POST /) one client may
|
|
||||||
// make per minute; the next is refused with 429 Too Many Requests.
|
|
||||||
const LoginAttemptsPerMinute = 5
|
|
||||||
|
|
||||||
// MaintenanceRetryAfterSeconds is the Retry-After, in seconds, sent with
|
|
||||||
// the 503 that the image routes answer while maintenance mode is on.
|
|
||||||
const MaintenanceRetryAfterSeconds = 300
|
|
||||||
|
|
||||||
// SetupRoutes configures all HTTP routes.
|
// SetupRoutes configures all HTTP routes.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
@@ -39,7 +28,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
|
s.router.Use(middleware.Timeout(HTTPWriteTimeout))
|
||||||
|
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
@@ -61,36 +50,25 @@ func (s *Server) SetupRoutes() {
|
|||||||
// token cookie is independent of the session cookie, so it also
|
// token cookie is independent of the session cookie, so it also
|
||||||
// covers the login POST, where no session exists yet. LimitBody caps
|
// covers the login POST, where no session exists yet. LimitBody caps
|
||||||
// the POST body ahead of CSRF, which reads its token from that body.
|
// the POST body ahead of CSRF, which reads its token from that body.
|
||||||
// The login POST is rate limited per client after both, so every
|
|
||||||
// attempt that reaches the signing key comparison is counted.
|
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||||
r.Use(s.h.CSRF())
|
r.Use(s.h.CSRF())
|
||||||
r.Get("/", s.h.HandleRoot())
|
r.Get("/", s.h.HandleRoot())
|
||||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
|
r.Post("/", s.h.HandleRoot())
|
||||||
Post("/", s.h.HandleRoot())
|
|
||||||
r.Post("/generate", s.h.HandleGenerateURL())
|
r.Post("/generate", s.h.HandleGenerateURL())
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Get("/logout", s.h.HandleLogout())
|
s.router.Get("/logout", s.h.HandleLogout())
|
||||||
|
|
||||||
// Image routes, refused while maintenance mode is on. Only these: the
|
// Main image proxy route
|
||||||
// image's Docker HEALTHCHECK requests the health check, a 503 there
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
// would make the container unhealthy, and upaas marks a deploy failed
|
s.router.Get("/v1/image/*", s.h.HandleImage())
|
||||||
// when its container is unhealthy.
|
s.router.Head("/v1/image/*", s.h.HandleImage())
|
||||||
s.router.Group(func(r chi.Router) {
|
|
||||||
r.Use(s.refuseDuringMaintenance)
|
|
||||||
|
|
||||||
// Main image proxy route
|
// Encrypted image URL route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||||
r.Get("/v1/image/*", s.h.HandleImage())
|
// browsers with content type
|
||||||
r.Head("/v1/image/*", s.h.HandleImage())
|
s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
||||||
|
|
||||||
// Encrypted image URL route
|
|
||||||
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
|
||||||
// browsers with content type
|
|
||||||
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
|
||||||
})
|
|
||||||
|
|
||||||
// Metrics endpoint with auth
|
// Metrics endpoint with auth
|
||||||
if s.config.MetricsUsername != "" {
|
if s.config.MetricsUsername != "" {
|
||||||
@@ -100,30 +78,3 @@ func (s *Server) SetupRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// refuseDuringMaintenance answers a request with 503 Service Unavailable,
|
|
||||||
// a Retry-After header and a JSON error body while maintenance mode is on,
|
|
||||||
// and passes it on otherwise. The body has the fields of the JSON errors
|
|
||||||
// the image handlers send.
|
|
||||||
func (s *Server) refuseDuringMaintenance(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.MaintenanceMode() {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(MaintenanceRetryAfterSeconds))
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
|
|
||||||
err := json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"error": "down for maintenance, try again later",
|
|
||||||
"status": http.StatusServiceUnavailable,
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("json encode error", "error", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// shutdownRecorder is an fx.Shutdowner that sends the options of each
|
|
||||||
// shutdown request on requests.
|
|
||||||
type shutdownRecorder struct {
|
|
||||||
requests chan []fx.ShutdownOption
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r shutdownRecorder) Shutdown(opts ...fx.ShutdownOption) error {
|
|
||||||
r.requests <- opts
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSentryInitFailureFailsStartup checks that a Sentry DSN that cannot be
|
|
||||||
// used makes the server's start hook fail, so fx stops what has already
|
|
||||||
// started, instead of the process exiting from a goroutine.
|
|
||||||
func TestSentryInitFailureFailsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = New(lc, Params{
|
|
||||||
Logger: log,
|
|
||||||
Globals: &globals.Globals{Appname: "pixad"},
|
|
||||||
Config: &config.Config{SentryDSN: "not-a-dsn"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = lc.Start(t.Context())
|
|
||||||
t.Logf("Start() error = %v", err)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Start() error = nil, want the Sentry initialization error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListenErrorRequestsShutdownWithExitCode1 occupies the server's port
|
|
||||||
// and checks that the listen error asks fx to shut down with exit code 1.
|
|
||||||
func TestListenErrorRequestsShutdownWithExitCode1(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
busy, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Listen() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = busy.Close() })
|
|
||||||
|
|
||||||
addr, ok := busy.Addr().(*net.TCPAddr)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("listener address %v is not a TCP address", busy.Addr())
|
|
||||||
}
|
|
||||||
|
|
||||||
requests := make(chan []fx.ShutdownOption, 1)
|
|
||||||
s := &Server{
|
|
||||||
log: slog.New(slog.DiscardHandler),
|
|
||||||
config: &config.Config{Port: addr.Port},
|
|
||||||
shutdowner: shutdownRecorder{requests: requests},
|
|
||||||
}
|
|
||||||
s.httpServer = s.newHTTPServer()
|
|
||||||
|
|
||||||
go s.serveUntilShutdown()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case opts := <-requests:
|
|
||||||
t.Logf("shutdown options = %v", opts)
|
|
||||||
|
|
||||||
if len(opts) != 1 || opts[0] != fx.ExitCode(1) {
|
|
||||||
t.Errorf("shutdown options = %v, want [fx.ExitCode(1)]", opts)
|
|
||||||
}
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("no shutdown was requested after the listen error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,12 +41,9 @@ func goldenVectors() []goldenVector {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: testFormatWebP,
|
Format: testFormatWebP,
|
||||||
Quality: 85,
|
|
||||||
FitMode: testFitCover,
|
|
||||||
},
|
},
|
||||||
// Signed data:
|
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
|
||||||
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
|
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
|
||||||
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
|
|
||||||
wantSignedPath: testSignedPath,
|
wantSignedPath: testSignedPath,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,12 +55,10 @@ func goldenVectors() []goldenVector {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: testFormatWebP,
|
Format: testFormatWebP,
|
||||||
Quality: 85,
|
|
||||||
FitMode: testFitCover,
|
|
||||||
},
|
},
|
||||||
// Signed data:
|
// Signed data:
|
||||||
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
|
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
|
||||||
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
|
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
|
||||||
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
|
||||||
"%3Ftoken=abc&v=2/800x600.webp",
|
"%3Ftoken=abc&v=2/800x600.webp",
|
||||||
},
|
},
|
||||||
@@ -76,34 +71,11 @@ func goldenVectors() []goldenVector {
|
|||||||
Width: 0,
|
Width: 0,
|
||||||
Height: 0,
|
Height: 0,
|
||||||
Format: testFormatPNG,
|
Format: testFormatPNG,
|
||||||
Quality: 85,
|
|
||||||
FitMode: testFitCover,
|
|
||||||
},
|
},
|
||||||
// Signed data:
|
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
|
||||||
// "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover"
|
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
|
||||||
wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=",
|
|
||||||
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "non-default quality and fit",
|
|
||||||
req: signature.Request{
|
|
||||||
SourceHost: testHost,
|
|
||||||
SourcePath: testPath,
|
|
||||||
SourceQuery: "",
|
|
||||||
Width: 800,
|
|
||||||
Height: 600,
|
|
||||||
Format: testFormatWebP,
|
|
||||||
Quality: 40,
|
|
||||||
FitMode: testFitContain,
|
|
||||||
},
|
|
||||||
// Signed data:
|
|
||||||
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain"
|
|
||||||
wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=",
|
|
||||||
// The path is the same as for the default quality and fit:
|
|
||||||
// q=40&fit=contain go in the query string next to sig and
|
|
||||||
// exp (imgcache.Service.GenerateSignedURL adds all four).
|
|
||||||
wantSignedPath: testSignedPath,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +84,11 @@ func goldenVectors() []goldenVector {
|
|||||||
// hardcoded signing key.
|
// hardcoded signing key.
|
||||||
//
|
//
|
||||||
// If any of these assertions fail, the signed byte format
|
// If any of these assertions fail, the signed byte format
|
||||||
// ("host:path:query:width:height:format:expiration:quality:fit"), the
|
// ("host:path:query:width:height:format:expiration"), the base64url
|
||||||
// base64url encoding, or the signed URL layout has changed. Update these
|
// encoding, or the signed URL layout has changed. Such a change breaks
|
||||||
// constants only when that change is intended.
|
// every signature already issued to clients, so it must be made
|
||||||
|
// deliberately: update these constants only as part of an intentional,
|
||||||
|
// documented signature format migration.
|
||||||
func TestSigner_GoldenVectors(t *testing.T) {
|
func TestSigner_GoldenVectors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package signature_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
|
||||||
)
|
|
||||||
|
|
||||||
// signedQualityFitRequest returns a request signed for quality 85 and fit
|
|
||||||
// mode "cover", the effective defaults the handler applies before
|
|
||||||
// verification.
|
|
||||||
func signedQualityFitRequest(signer *signature.Signer) *signature.Request {
|
|
||||||
req := &signature.Request{
|
|
||||||
SourceHost: testHost,
|
|
||||||
SourcePath: testPath,
|
|
||||||
Width: 800,
|
|
||||||
Height: 600,
|
|
||||||
Format: testFormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: testFitCover,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are
|
|
||||||
// covered by the signature: a URL signed for one quality or fit mode must
|
|
||||||
// not verify when replayed with a different quality or fit mode. This is the
|
|
||||||
// amplification vector from the issue — one signed URL replayed across many
|
|
||||||
// quality and fit values yields many unauthorized cache entries and
|
|
||||||
// transcodes — so it must be rejected.
|
|
||||||
func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
signer := signature.New("test-secret-key")
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
tamper func(r *signature.Request)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "replayed with different quality",
|
|
||||||
tamper: func(r *signature.Request) { r.Quality = 40 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "replayed with different fit mode",
|
|
||||||
tamper: func(r *signature.Request) { r.FitMode = testFitContain },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range cases {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := signedQualityFitRequest(signer)
|
|
||||||
tt.tamper(req)
|
|
||||||
|
|
||||||
err := signer.Verify(req)
|
|
||||||
if !errors.Is(err, signature.ErrInvalid) {
|
|
||||||
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,15 +37,6 @@ type Request struct {
|
|||||||
Height int
|
Height int
|
||||||
// Format is the requested output format (e.g. "webp").
|
// Format is the requested output format (e.g. "webp").
|
||||||
Format string
|
Format string
|
||||||
// Quality is the requested output quality (1-100) for lossy formats.
|
|
||||||
// It is the effective value the request resolves to: callers pass the
|
|
||||||
// default quality when the request omits the parameter, so an omitted
|
|
||||||
// quality signs identically to that same value stated explicitly.
|
|
||||||
Quality int
|
|
||||||
// FitMode is how the image is fit into the requested dimensions
|
|
||||||
// (e.g. "cover"). Like Quality it is the effective value: callers pass
|
|
||||||
// the default fit mode when the request omits the parameter.
|
|
||||||
FitMode string
|
|
||||||
// Signature is the HMAC signature to verify.
|
// Signature is the HMAC signature to verify.
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is the signature expiration timestamp.
|
// Expires is the signature expiration timestamp.
|
||||||
@@ -65,8 +56,7 @@ func New(secretKey string) *Signer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given request.
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
||||||
// The signature covers: host + path + query + width + height + format +
|
// The signature covers: host + path + query + width + height + format + expiration.
|
||||||
// expiration + quality + fit.
|
|
||||||
func (s *Signer) Sign(req *Request) string {
|
func (s *Signer) Sign(req *Request) string {
|
||||||
data := s.buildSignatureData(req)
|
data := s.buildSignatureData(req)
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
mac := hmac.New(sha256.New, s.secretKey)
|
||||||
@@ -78,8 +68,7 @@ func (s *Signer) Sign(req *Request) string {
|
|||||||
|
|
||||||
// Verify checks if the signature on the request is valid and not expired.
|
// Verify checks if the signature on the request is valid and not expired.
|
||||||
// Signatures are exact-match only: every component of the signed data
|
// Signatures are exact-match only: every component of the signed data
|
||||||
// (host, path, query, dimensions, format, expiration, quality, fit) must
|
// (host, path, query, dimensions, format, expiration) must match exactly.
|
||||||
// match exactly.
|
|
||||||
// No suffix matching, wildcard matching, or partial matching is supported.
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
||||||
// "other.cdn.example.com", and vice versa.
|
// "other.cdn.example.com", and vice versa.
|
||||||
@@ -153,13 +142,11 @@ func (s *Signer) GenerateSignedURL(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
// buildSignatureData creates the string to be signed.
|
||||||
// Format: "host:path:query:width:height:format:expiration:quality:fit"
|
// Format: "host:path:query:width:height:format:expiration"
|
||||||
// All components are used verbatim (exact match). No normalization,
|
// All components are used verbatim (exact match). No normalization,
|
||||||
// suffix matching, or wildcard expansion is performed. Quality and fit
|
// suffix matching, or wildcard expansion is performed.
|
||||||
// are the effective transform values, so replaying a signed URL with a
|
|
||||||
// different quality or fit mode fails verification.
|
|
||||||
func (s *Signer) buildSignatureData(req *Request) string {
|
func (s *Signer) buildSignatureData(req *Request) string {
|
||||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
||||||
req.SourceHost,
|
req.SourceHost,
|
||||||
req.SourcePath,
|
req.SourcePath,
|
||||||
req.SourceQuery,
|
req.SourceQuery,
|
||||||
@@ -167,8 +154,6 @@ func (s *Signer) buildSignatureData(req *Request) string {
|
|||||||
req.Height,
|
req.Height,
|
||||||
req.Format,
|
req.Format,
|
||||||
req.Expires.Unix(),
|
req.Expires.Unix(),
|
||||||
req.Quality,
|
|
||||||
req.FitMode,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const (
|
|||||||
testPath = "/photos/cat.jpg"
|
testPath = "/photos/cat.jpg"
|
||||||
testFormatWebP = "webp"
|
testFormatWebP = "webp"
|
||||||
testFormatPNG = "png"
|
testFormatPNG = "png"
|
||||||
testFitCover = "cover"
|
|
||||||
testFitContain = "contain"
|
|
||||||
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
||||||
testSig = "abc123"
|
testSig = "abc123"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
id="width"
|
id="width"
|
||||||
name="width"
|
name="width"
|
||||||
min="0"
|
min="0"
|
||||||
max="8192"
|
max="10000"
|
||||||
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
||||||
placeholder="0 = original"
|
placeholder="0 = original"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
id="height"
|
id="height"
|
||||||
name="height"
|
name="height"
|
||||||
min="0"
|
min="0"
|
||||||
max="8192"
|
max="10000"
|
||||||
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
||||||
placeholder="0 = original"
|
placeholder="0 = original"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
|||||||
+58
-12
@@ -3,16 +3,20 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). The linter is never installed on the host: golangci-lint
|
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||||
# runs only inside a container, Dockerfile.lint or the Dockerfile lint
|
# it is installed from a hash-verified GitHub release archive (never
|
||||||
# stage (see script/lint). A C compiler and the CGO image libraries
|
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are
|
||||||
# (pkg-config, vips, libheif) are installed for the govips bindings.
|
# installed for the govips bindings.
|
||||||
# Both Dockerfiles run this script too, so their build dependencies are
|
|
||||||
# the ones listed here.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
||||||
|
GOLANGCI_LINT_VERSION="2.12.2"
|
||||||
|
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
||||||
|
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
||||||
|
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|
||||||
@@ -53,13 +57,54 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# verify_sha256 <file> <expected-hash>
|
||||||
|
verify_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# apt has no golangci-lint package: install a pinned release archive
|
||||||
|
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
||||||
|
install_golangci_lint_release() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
||||||
|
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
||||||
|
*)
|
||||||
|
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/$name.tar.gz" \
|
||||||
|
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
||||||
|
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
||||||
|
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
||||||
|
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_golangci_lint() {
|
||||||
|
if ! missing golangci-lint; then return 0; fi
|
||||||
|
detect_pkgmgr
|
||||||
|
case "$PKGMGR" in
|
||||||
|
apt) install_golangci_lint_release ;;
|
||||||
|
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# CGO dependencies for govips (image processing)
|
# CGO dependencies for govips (image processing)
|
||||||
ensure_cgo_deps() {
|
ensure_cgo_deps() {
|
||||||
# cgo compiles with gcc on Linux; build-base and build-essential
|
|
||||||
# also bring the C library headers.
|
|
||||||
if missing gcc; then
|
|
||||||
pkg_install gcc build-essential gcc build-base
|
|
||||||
fi
|
|
||||||
if missing pkg-config; then
|
if missing pkg-config; then
|
||||||
pkg_install pkg-config pkg-config pkg-config pkgconfig
|
pkg_install pkg-config pkg-config pkg-config pkgconfig
|
||||||
fi
|
fi
|
||||||
@@ -78,8 +123,9 @@ main() {
|
|||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
|
||||||
# Go toolchain
|
# Go toolchain and linter
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
ensure_golangci_lint
|
||||||
|
|
||||||
# CGO image libraries
|
# CGO image libraries
|
||||||
ensure_cgo_deps
|
ensure_cgo_deps
|
||||||
|
|||||||
+2
-4
@@ -16,10 +16,8 @@ main() {
|
|||||||
# removed on exit.
|
# removed on exit.
|
||||||
key="$(head -c 32 /dev/urandom | base64)"
|
key="$(head -c 32 /dev/urandom | base64)"
|
||||||
|
|
||||||
# Docker 25 and later probe every 5 seconds during the image's
|
# --health-interval=1s overrides the image's 30s interval so the
|
||||||
# 10-second start period. --health-interval=1s makes it probe every
|
# first probe does not use up the whole wait.
|
||||||
# second after that instead of every 30, so a slow start still shows
|
|
||||||
# as healthy within the 30-second wait.
|
|
||||||
cid="$(docker create --health-interval=1s \
|
cid="$(docker create --health-interval=1s \
|
||||||
-e PIXA_SIGNING_KEY="$key" "$("$SCRIPT_DIR/projectname")")"
|
-e PIXA_SIGNING_KEY="$key" "$("$SCRIPT_DIR/projectname")")"
|
||||||
# Remove the container on any exit; turning signals into exit makes
|
# Remove the container on any exit; turning signals into exit makes
|
||||||
|
|||||||
+13
-27
@@ -1,37 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run golangci-lint over the whole tree. This is the only
|
# script/lint: run the linter. CGO dependencies (pkg-config, vips,
|
||||||
# way the linter is run, everywhere; it is never installed on the host.
|
# libheif) come from nix-shell when not already available (e.g. inside
|
||||||
#
|
# a Docker build or an existing nix-shell).
|
||||||
# Inside a container it runs the linter. Anywhere else it builds
|
|
||||||
# Dockerfile.lint, whose last step runs this script again inside that
|
|
||||||
# container.
|
|
||||||
#
|
|
||||||
# Dockerfile.lint and the Dockerfile lint stage set container=docker
|
|
||||||
# (the systemd convention for marking a container) to say where we are.
|
|
||||||
# /.dockerenv cannot: it is missing inside build steps, and present on
|
|
||||||
# hosts that are themselves containers.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
run_with_cgo_deps() {
|
||||||
cd "$ROOT"
|
if command -v pkg-config >/dev/null 2>&1; then
|
||||||
if [ "${container:-}" = docker ]; then
|
sh -c "$1"
|
||||||
# `golangci-lint config verify` is not run: it fetches its JSON
|
|
||||||
# schema over an unpinned live HTTPS call, which REPO_POLICIES.md
|
|
||||||
# forbids.
|
|
||||||
echo "Running linter..."
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
else
|
else
|
||||||
# A new CACHEBUST on every run means the lint step is never
|
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||||
# served from cache (see Dockerfile.lint). The cacheonly output
|
|
||||||
# leaves no image behind.
|
|
||||||
docker build \
|
|
||||||
--progress=plain \
|
|
||||||
--build-arg CACHEBUST="$(date +%s)-$$" \
|
|
||||||
--output=type=cacheonly \
|
|
||||||
-f Dockerfile.lint .
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Running linter..."
|
||||||
|
run_with_cgo_deps "golangci-lint run"
|
||||||
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ run_with_cgo_deps() {
|
|||||||
if command -v pkg-config >/dev/null 2>&1; then
|
if command -v pkg-config >/dev/null 2>&1; then
|
||||||
sh -c "$1"
|
sh -c "$1"
|
||||||
else
|
else
|
||||||
nix-shell -p pkg-config vips libheif git --run "$1"
|
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user