Compare commits
10
Commits
main
..
fc87c2117d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc87c2117d | ||
|
|
1a07e9e261 | ||
|
|
dd9c85d5ec | ||
|
|
0c99be939a | ||
|
|
56217cbf4a | ||
|
|
46a55ec15d | ||
|
|
1b920fe000 | ||
|
|
e98b998cb6 | ||
|
|
ed3f8770e6 | ||
|
|
2afe61e301 |
+22
-19
@@ -3,13 +3,14 @@
|
|||||||
# 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
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
# script/bootstrap installs the build dependencies and downloads the Go
|
||||||
|
# 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 go mod download
|
RUN script/bootstrap
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -28,20 +29,12 @@ 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
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
# Build dependencies and Go modules, as in the lint stage
|
||||||
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN GOTOOLCHAIN=auto go mod download
|
RUN script/bootstrap
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -49,8 +42,14 @@ COPY . .
|
|||||||
# Run tests
|
# Run tests
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Build with CGO enabled
|
# VERSION is declared here, not earlier: a new value reruns only the
|
||||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
# build, not script/bootstrap or the tests. CGO stays enabled for
|
||||||
|
# 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
|
||||||
@@ -68,8 +67,12 @@ RUN apk add --no-cache \
|
|||||||
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
|
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create non-root user, config directory, and data directory
|
# Create non-root user, config directory, and data directory. pixad
|
||||||
RUN adduser -D -H -s /sbin/nologin pixad && \
|
# gets uid and gid 65532, which host login and system accounts do not
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -6,16 +6,16 @@
|
|||||||
# 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
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
||||||
|
|
||||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
|
||||||
# this image needs the same C libraries the build does.
|
|
||||||
RUN apk add --no-cache build-base vips-dev libheif-dev pkgconfig
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Modules first for layer caching; go.mod/go.sum settle this layer's
|
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
||||||
# result, so it may safely be reused between runs.
|
# 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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
|||||||
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
|
`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
|
port changed only in a mounted config file is not seen by it: change
|
||||||
the port with `PORT`.
|
the port with `PORT`.
|
||||||
- **First run:** create the host directory. It may be owned by root: the
|
- **First run:** create the host directory, owned by root or by uid
|
||||||
container gives it to its `pixad` user when it starts.
|
`65532` and gid `65532`. The server runs as the container's `pixad`
|
||||||
|
user, which has that uid and gid, and the container gives the
|
||||||
|
directory to `pixad` when it starts.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -84,7 +86,10 @@ 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.
|
||||||
In-process caching of request-to-output mappings targets 1-5k r/s.
|
Toward a target of 1-5k r/s, pixa keeps in memory the content types of
|
||||||
|
the 10,000 transformed images most recently cached or served, so a
|
||||||
|
cache hit on one of them reads only the image file from disk and not
|
||||||
|
the metadata file stored beside it.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -100,16 +105,29 @@ 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
|
The login form (`POST /`) is limited to 5 attempts per minute per client
|
||||||
address, counting an IPv6 client by its /64; an attempt over the limit is
|
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||||
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
address comes from `X-Forwarded-For` only when the proxy's address is in
|
address comes from `X-Forwarded-For` only when the address pixa sees for
|
||||||
`trusted_proxies`; otherwise all users behind the proxy are counted as one
|
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
||||||
client. With the default `trusted_proxies` (the RFC 1918 ranges), a client
|
users behind the proxy are counted as one client. That address is not always
|
||||||
with a private address can choose the address it is counted by through its own
|
the proxy's own: a proxy on the Docker host that connects to pixa over
|
||||||
`X-Forwarded-For`, whether it connects directly or through the proxy, because
|
`127.0.0.1` is seen as the gateway of the container's Docker network, such as
|
||||||
its own address is trusted too. Setting `trusted_proxies` to the proxy's own
|
`172.17.0.1` on the default bridge, and one that connects through another of the
|
||||||
address closes this.
|
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
|
### Image Metadata
|
||||||
|
|
||||||
@@ -163,19 +181,27 @@ Where:
|
|||||||
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
||||||
anything else, an empty `fit=` included, is refused with 400
|
anything else, an empty `fit=` included, is refused with 400
|
||||||
|
|
||||||
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
|
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
|
||||||
WebP with expiration 1704067200, default quality and fit:
|
of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
||||||
|
compares it exactly, so a signature encoded without padding, as Node's
|
||||||
|
`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:85:cover`
|
||||||
2. Compute HMAC-SHA256 with your secret key
|
2. Compute HMAC-SHA256 of it with the signing key
|
||||||
3. Base64URL-encode the result
|
3. Base64URL-encode the result, keeping the `=` padding:
|
||||||
|
`-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=`
|
||||||
4. URL:
|
4. URL:
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200`
|
||||||
|
|
||||||
For the same image at quality 40 with fit `contain`, the input ends in
|
For the same image at quality 40 with fit `contain`, the input ends in
|
||||||
`:40:contain` and the URL is
|
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`,
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200&q=40&fit=contain`.
|
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:**
|
||||||
|
|
||||||
@@ -212,6 +238,10 @@ 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_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 |
|
||||||
@@ -220,7 +250,14 @@ variables set by the file's `env:` section are checked the same way.
|
|||||||
|
|
||||||
Key settings in more detail:
|
Key settings in more detail:
|
||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
- `access_control_allow_origin` — 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
|
||||||
- `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,
|
||||||
@@ -237,11 +274,23 @@ Key settings in more detail:
|
|||||||
`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 your proxy's address range if it is not already
|
startup. Set this to the address pixa sees for requests that come through
|
||||||
covered by the defaults
|
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
to trust nothing else (see the login limit under Routes). For a proxy on
|
||||||
- `upstream_max_response_size` — max origin response size
|
the Docker host that connects to pixa over `127.0.0.1`, that address is the
|
||||||
- `downstream_timeout` — client response timeout
|
gateway of the container's Docker network (`172.17.0.1` on the default
|
||||||
|
bridge), not the proxy's own address; a proxy that connects through another of
|
||||||
|
the host's addresses is seen with that address. To be sure which address it
|
||||||
|
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
||||||
|
through the proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream
|
||||||
|
host, as a duration such as `30s` (the default) or `2m`
|
||||||
|
- `upstream_max_response_size` — largest upstream response accepted, in
|
||||||
|
bytes; 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, so keep it
|
||||||
|
longer than `upstream_fetch_timeout`
|
||||||
- `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
|
||||||
|
|||||||
@@ -30,6 +30,73 @@ exhaustion
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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; for a variant it holds, `Lookup`
|
||||||
|
skips the check of the disk and `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 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
|
- 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
|
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
|
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
||||||
@@ -267,7 +334,6 @@ exhaustion
|
|||||||
- 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
|
||||||
|
|||||||
+32
-1
@@ -8,6 +8,10 @@
|
|||||||
# this file's env: section is set while the file loads, so it overrides
|
# 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
|
# both the environment the process was started with and this file's own
|
||||||
# key.
|
# 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
|
||||||
@@ -50,7 +54,13 @@ 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 your proxy's address range.
|
# Uncomment to override the defaults with the address pixa sees for
|
||||||
|
# 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
|
||||||
@@ -61,6 +71,27 @@ 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
|
||||||
|
|
||||||
|
# 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, the upstream fetch
|
||||||
|
# included, so keep it longer than upstream_fetch_timeout (default: 60s)
|
||||||
|
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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/go-chi/httprate v0.16.0
|
github.com/go-chi/httprate v0.16.0
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
|||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.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=
|
||||||
|
|||||||
+175
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"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"
|
||||||
@@ -24,6 +25,10 @@ const (
|
|||||||
DefaultPort = 8080
|
DefaultPort = 8080
|
||||||
DefaultStateDir = "/var/lib/pixa"
|
DefaultStateDir = "/var/lib/pixa"
|
||||||
DefaultUpstreamConnectionsPerHost = 20
|
DefaultUpstreamConnectionsPerHost = 20
|
||||||
|
DefaultAccessControlAllowOrigin = "*"
|
||||||
|
DefaultUpstreamFetchTimeout = 30 * time.Second
|
||||||
|
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
||||||
|
DefaultDownstreamTimeout = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
@@ -44,6 +49,10 @@ const (
|
|||||||
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
|
||||||
@@ -69,6 +78,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")
|
errTooFewConnections = 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(
|
||||||
@@ -86,6 +96,10 @@ 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.
|
||||||
@@ -112,6 +126,23 @@ 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
|
||||||
|
|
||||||
|
// 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.
|
||||||
@@ -240,6 +271,14 @@ 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),
|
||||||
|
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,
|
||||||
@@ -354,7 +393,8 @@ func isKnownConfigKey(key string) bool {
|
|||||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyTrustedProxies, "env":
|
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||||
|
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +422,10 @@ func envVarNames() map[string]string {
|
|||||||
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +579,11 @@ 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 {
|
||||||
@@ -556,6 +605,82 @@ 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +798,19 @@ 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
|
||||||
@@ -794,6 +932,42 @@ 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,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
)
|
)
|
||||||
@@ -599,3 +600,244 @@ 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()...))
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
@@ -69,6 +70,10 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
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 {
|
||||||
@@ -92,6 +97,10 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
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) {
|
||||||
@@ -280,6 +289,30 @@ 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,14 +15,12 @@ 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
|
||||||
@@ -113,29 +111,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectMigrations reads the embedded schema directory and returns
|
// collectMigrations reads the embedded migrations directory and returns
|
||||||
// migration filenames sorted lexicographically.
|
// migration filenames sorted lexicographically.
|
||||||
func collectMigrations() ([]string, error) {
|
func collectMigrations() ([]string, error) {
|
||||||
entries, err := schemaFS.ReadDir("schema")
|
entries, err := fs.ReadDir(migrations.FS(), ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations []string
|
var filenames []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") {
|
||||||
migrations = append(migrations, entry.Name())
|
filenames = append(filenames, entry.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(migrations)
|
sort.Strings(filenames)
|
||||||
|
|
||||||
return migrations, nil
|
return filenames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||||
// by applying 000.sql if the table is missing.
|
// by applying 000_migration.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
|
||||||
|
|
||||||
@@ -150,9 +148,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := schemaFS.ReadFile("schema/000.sql")
|
content, err := fs.ReadFile(migrations.FS(), "000_migration.sql")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if log != nil {
|
if log != nil {
|
||||||
@@ -177,12 +175,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations, err := collectMigrations()
|
filenames, err := collectMigrations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range filenames {
|
||||||
version, parseErr := ParseMigrationVersion(migration)
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return parseErr
|
return parseErr
|
||||||
@@ -208,7 +206,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read and apply migration.
|
// Read and apply migration.
|
||||||
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
|
content, readErr := fs.ReadFile(migrations.FS(), 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 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,7 +103,8 @@ 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.
|
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
||||||
|
// that never expires, gives the zero Expires.
|
||||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
@@ -120,6 +121,11 @@ 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,
|
||||||
@@ -131,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
|
Expires: expires,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ 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
|
||||||
|
|||||||
@@ -220,6 +220,24 @@ func (s *Handlers) respondImageError(
|
|||||||
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(
|
||||||
@@ -235,7 +253,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if resp.ETag != "" {
|
if resp.ETag != "" {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
||||||
// for it needs a valid signature. No image is served for it: a request that
|
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
||||||
// passes the signature check gets 502 from the failed fetch, and one that
|
// request that passes the signature check gets 502 from the failed fetch, and
|
||||||
// fails the check gets 401.
|
// one that fails the check gets 401.
|
||||||
const signedHost = "signed.example.com"
|
const signedHost = "signed.example.com"
|
||||||
|
|
||||||
// getImage sends a GET for target to the image route of fix and returns the
|
// getImage sends a GET for target to the image route of fix and returns the
|
||||||
|
|||||||
@@ -89,8 +89,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 - encrypted URLs can be cached since they're immutable
|
// Cache headers: max-age ends at the URL's expiry
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
// Stream the response
|
// Stream the response
|
||||||
|
|||||||
+83
-28
@@ -14,6 +14,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ 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
|
||||||
@@ -49,12 +54,6 @@ 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
|
||||||
@@ -76,9 +75,10 @@ type Cache struct {
|
|||||||
evictionStarted bool
|
evictionStarted bool
|
||||||
evictionStopOnce sync.Once
|
evictionStopOnce sync.Once
|
||||||
|
|
||||||
// In-memory cache of variant metadata (content type, size) to avoid
|
// metaCache holds the content types of the variants most recently
|
||||||
// reading .meta files
|
// stored or served, so a hit does not read the variant's .meta file.
|
||||||
metaCache map[VariantKey]variantMeta
|
// It never stands in for the variant file, which is always opened.
|
||||||
|
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,6 +101,11 @@ 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,
|
||||||
@@ -109,7 +114,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: make(map[VariantKey]variantMeta),
|
metaCache: metaCache,
|
||||||
contentLocks: newContentLock(),
|
contentLocks: newContentLock(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +159,15 @@ type LookupResult struct {
|
|||||||
CacheStatus CacheStatus
|
CacheStatus CacheStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk. Hits touch the
|
// Lookup checks if a processed variant exists on disk: a variant held
|
||||||
// variant's LRU timestamp; a disabled cache always misses.
|
// in metaCache counts as present without a check of the disk. Hits
|
||||||
|
// touch the variant's LRU timestamp; a disabled cache always misses.
|
||||||
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
// Check variant storage directly - no DB needed for cache hits
|
// Check memory, then variant storage - no DB needed for cache hits
|
||||||
if !c.disabled && c.variants.Exists(cacheKey) {
|
if !c.disabled &&
|
||||||
|
(c.metaCache.Contains(cacheKey) || c.variants.Exists(cacheKey)) {
|
||||||
c.touchVariant(ctx, cacheKey)
|
c.touchVariant(ctx, cacheKey)
|
||||||
|
|
||||||
return &LookupResult{
|
return &LookupResult{
|
||||||
@@ -177,13 +184,28 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVariant returns a reader, size, and content type for a cached variant.
|
// GetVariant returns a reader, size, and content type for a cached
|
||||||
|
// variant. The content type comes from metaCache, or else from the
|
||||||
|
// variant's .meta file and is then kept in metaCache.
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.variants.LoadWithMeta(cacheKey)
|
contentType, known := c.metaCache.Get(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
|
||||||
@@ -286,6 +308,8 @@ 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 (?, ?, ?)
|
||||||
@@ -419,19 +443,21 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get actual item count and total size from content tables
|
// Count and size the cached source images and processed variants. A
|
||||||
err = c.db.QueryRowContext(ctx,
|
// disabled cache holds none, whatever rows an earlier run left.
|
||||||
`SELECT COUNT(*) FROM request_cache`,
|
if !c.disabled {
|
||||||
).Scan(&stats.TotalItems)
|
err = c.db.QueryRowContext(ctx, `
|
||||||
if err != nil {
|
SELECT (SELECT COUNT(*) FROM source_content)
|
||||||
c.log.Warn("failed to count cache items for stats", "error", err)
|
+ (SELECT COUNT(*) FROM variant_content)
|
||||||
}
|
`).Scan(&stats.TotalItems)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = c.db.QueryRowContext(ctx,
|
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
||||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`,
|
if err != nil {
|
||||||
).Scan(&stats.TotalSizeBytes)
|
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||||
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
|
||||||
@@ -481,6 +507,35 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
c.metaCache.Add(cacheKey, contentType)
|
||||||
|
|
||||||
|
return reader, size, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
||||||
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
||||||
// database.
|
// database.
|
||||||
|
|||||||
@@ -271,7 +271,9 @@ 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.
|
// file. The metaCache entry goes before the files; a GetVariant that
|
||||||
|
// 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))
|
||||||
@@ -279,6 +281,8 @@ 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
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ 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 the signature expiration timestamp
|
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
||||||
|
// of an encrypted URL; the zero time if it has none
|
||||||
Expires time.Time
|
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
|
||||||
@@ -162,9 +163,11 @@ type ImageCache interface {
|
|||||||
|
|
||||||
// CacheStats contains cache statistics
|
// CacheStats contains cache statistics
|
||||||
type CacheStats struct {
|
type CacheStats struct {
|
||||||
// TotalItems is the number of cached items
|
// TotalItems is the number of cached source images plus processed
|
||||||
|
// variants
|
||||||
TotalItems int64
|
TotalItems int64
|
||||||
// TotalSizeBytes is the total size of cached content
|
// TotalSizeBytes is the total size of cached source images and
|
||||||
|
// processed variants
|
||||||
TotalSizeBytes int64
|
TotalSizeBytes int64
|
||||||
// HitCount is the number of cache hits
|
// HitCount is the number of cache hits
|
||||||
HitCount int64
|
HitCount int64
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -143,7 +143,8 @@ 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 {
|
||||||
s.cache.IncrementStats(ctx, true, 0)
|
// Counted also when the request context has ended meanwhile
|
||||||
|
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
|
||||||
|
|
||||||
return &ImageResponse{
|
return &ImageResponse{
|
||||||
Content: reader,
|
Content: reader,
|
||||||
@@ -155,12 +156,15 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss - check if we have source content cached
|
// Cache miss - process the cached source or fetch it, then count the
|
||||||
|
// 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)
|
||||||
|
|
||||||
s.cache.IncrementStats(ctx, false, 0)
|
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -268,22 +272,20 @@ func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content
|
// processFromSourceOrFetch processes an image, using cached source content
|
||||||
// if available.
|
// if available. It also returns the number of bytes fetched from upstream,
|
||||||
|
// 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, error) {
|
) (*ImageResponse, int64, 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 {
|
||||||
s.log.Warn("source lookup failed", "error", err)
|
s.log.Warn("source lookup failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var sourceData []byte
|
||||||
sourceData []byte
|
|
||||||
fetchBytes int64
|
|
||||||
)
|
|
||||||
|
|
||||||
if contentHash != "" {
|
if contentHash != "" {
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
s.log.Debug("using cached source", "hash", contentHash)
|
||||||
@@ -292,26 +294,25 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
|
|
||||||
// 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 len(sourceData) == 0 {
|
if len(sourceData) == 0 {
|
||||||
resp, err := s.fetchAndProcess(ctx, req, cacheKey)
|
return s.fetchAndProcess(ctx, req, cacheKey)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process using cached source
|
// Process using cached source; nothing was fetched from upstream
|
||||||
fetchBytes = int64(len(sourceData))
|
resp, err := s.processAndStore(
|
||||||
|
ctx, req, cacheKey, sourceData, int64(len(sourceData)),
|
||||||
|
)
|
||||||
|
|
||||||
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
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, error) {
|
) (*ImageResponse, int64, error) {
|
||||||
// Fetch from upstream
|
// Fetch from upstream
|
||||||
sourceURL := req.SourceURL()
|
sourceURL := req.SourceURL()
|
||||||
|
|
||||||
@@ -330,20 +331,20 @@ func (s *Service) fetchAndProcess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("upstream fetch failed: %w", err)
|
return nil, 0, fmt.Errorf("upstream fetch failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, fmt.Errorf("failed to read upstream response: %w", err)
|
return nil, fetchBytes, 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 {
|
||||||
@@ -368,7 +369,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, fmt.Errorf("content validation failed: %w", err)
|
return nil, fetchBytes, fmt.Errorf("content validation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
@@ -378,7 +379,9 @@ func (s *Service) fetchAndProcess(
|
|||||||
// Continue even if caching fails
|
// Continue even if caching fails
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
resp, err := s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
||||||
|
|
||||||
|
return resp, fetchBytes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndStore processes an image and stores the result.
|
// processAndStore processes an image and stores the result.
|
||||||
@@ -406,6 +409,9 @@ func (s *Service) processAndStore(
|
|||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,6 +14,7 @@ import (
|
|||||||
"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 {
|
||||||
@@ -125,7 +129,7 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(),
|
_, err = db.ExecContext(t.Context(),
|
||||||
`DROP TABLE request_cache; DROP TABLE output_content`)
|
`DROP TABLE source_content; DROP TABLE variant_content`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -183,3 +187,313 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -506,32 +506,43 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
// LoadWithSize returns a reader and file size for the content at the
|
||||||
// the given key.
|
// given key.
|
||||||
func (s *VariantStorage) LoadWithMeta(
|
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||||
|
// the given key.
|
||||||
|
func (s *VariantStorage) LoadWithMeta(
|
||||||
|
key VariantKey,
|
||||||
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
f, size, err := s.LoadWithSize(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load metadata for content type
|
// Load metadata for content type
|
||||||
contentType := "application/octet-stream" // fallback
|
contentType := "application/octet-stream" // fallback
|
||||||
|
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 {
|
||||||
@@ -541,7 +552,7 @@ func (s *VariantStorage) LoadWithMeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, stat.Size(), contentType, nil
|
return f, size, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exists checks if content exists at the given key.
|
// Exists checks if content exists at the given key.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,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{"*"},
|
AllowedOrigins: []string{s.config.AccessControlAllowOrigin},
|
||||||
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,6 +9,53 @@ 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,7 +14,6 @@ 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.
|
||||||
@@ -30,7 +29,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: HTTPWriteTimeout,
|
WriteTimeout: s.config.DownstreamTimeout,
|
||||||
IdleTimeout: HTTPIdleTimeout,
|
IdleTimeout: HTTPIdleTimeout,
|
||||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ 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.
|
// each assertion compares the server field to its constant, or, for
|
||||||
|
// 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{Port: 8080}}
|
s := &Server{config: &config.Config{
|
||||||
|
Port: 8080,
|
||||||
|
DownstreamTimeout: 45 * time.Second,
|
||||||
|
}}
|
||||||
|
|
||||||
srv := s.newHTTPServer()
|
srv := s.newHTTPServer()
|
||||||
|
|
||||||
@@ -26,7 +30,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, HTTPWriteTimeout},
|
{"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
|
||||||
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ func newTestServer(t *testing.T) *Server {
|
|||||||
|
|
||||||
stateDir := t.TempDir()
|
stateDir := t.TempDir()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Debug: true,
|
Debug: true,
|
||||||
SigningKey: testSigningKey,
|
SigningKey: testSigningKey,
|
||||||
StateDir: stateDir,
|
StateDir: stateDir,
|
||||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
DownstreamTimeout: config.DefaultDownstreamTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(HTTPWriteTimeout))
|
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
|
||||||
|
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
|
|||||||
+9
-2
@@ -5,8 +5,10 @@
|
|||||||
# 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). The linter is never installed on the host: golangci-lint
|
||||||
# runs only inside a container, Dockerfile.lint or the Dockerfile lint
|
# runs only inside a container, Dockerfile.lint or the Dockerfile lint
|
||||||
# stage (see script/lint). CGO image libraries (pkg-config, vips,
|
# stage (see script/lint). A C compiler and the CGO image libraries
|
||||||
# libheif) are installed for the govips bindings.
|
# (pkg-config, vips, libheif) are 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)"
|
||||||
@@ -53,6 +55,11 @@ missing() {
|
|||||||
|
|
||||||
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user