Compare commits
41
Commits
8f0bc2f04c
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd7d7ed205 | ||
|
|
bce8860c2e | ||
|
|
99735f479b | ||
|
|
baf457d21e | ||
|
|
0c99be939a | ||
|
|
56217cbf4a | ||
|
|
46a55ec15d | ||
|
|
1b920fe000 | ||
|
|
e98b998cb6 | ||
|
|
ed3f8770e6 | ||
|
|
2afe61e301 | ||
|
|
05678eaae5 | ||
|
|
be060a8305 | ||
|
|
e410146fb6 | ||
|
|
6010f5beb0 | ||
|
|
f149813c7e | ||
|
|
45869572ff | ||
|
|
0f3700f7f5 | ||
|
|
582ff66ba6 | ||
|
|
f8d40b89a7 | ||
|
|
50123b2a6d | ||
|
|
2f7365cc9b | ||
|
|
0f5bd51b09 | ||
|
|
db784bf561 | ||
|
|
b7c1226c38 | ||
|
|
10eab440e7 | ||
|
|
3cfcda0730 | ||
|
|
1798cba96c | ||
|
|
37d49ade11 | ||
|
|
b4e5300feb | ||
|
|
4f95cb6a37 | ||
|
|
6f416eac31 | ||
|
|
b95ef1eb69 | ||
|
|
a96eba8083 | ||
|
|
04b5db6fbf | ||
|
|
63fbc98e63 | ||
|
|
61f42e6602 | ||
|
|
5d0b5f864e | ||
|
|
6573b9d1ef | ||
|
|
275e145a6d | ||
|
|
b6e9ac2a93 |
@@ -7,3 +7,4 @@ jobs:
|
|||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
|
- run: script/docker-smoke
|
||||||
|
|||||||
+24
-107
@@ -1,117 +1,34 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.24"
|
timeout: 5m
|
||||||
tests: false
|
modules-download-mode: readonly
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
default: all
|
||||||
# Additional linters requested
|
disable:
|
||||||
- testifylint # Checks usage of github.com/stretchr/testify
|
# Genuinely incompatible with project patterns
|
||||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
- exhaustruct # Requires all struct fields
|
||||||
# - tagliatelle # Disabled: we need snake_case for external API compatibility
|
- depguard # Dependency allow/block lists
|
||||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
- godot # Requires comments to end with periods
|
||||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- nestif # Reports deeply nested if statements
|
- wrapcheck # Too verbose for internal packages
|
||||||
- mnd # An analyzer to detect magic numbers
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
- lll # Reports long lines
|
settings:
|
||||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
|
||||||
- gochecknoglobals # Check that no global variables exist
|
|
||||||
|
|
||||||
# Default/existing linters that are commonly useful
|
|
||||||
- govet
|
|
||||||
- errcheck
|
|
||||||
- staticcheck
|
|
||||||
- unused
|
|
||||||
- ineffassign
|
|
||||||
- misspell
|
|
||||||
- revive
|
|
||||||
- gosec
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
lll:
|
lll:
|
||||||
line-length: 120
|
line-length: 88
|
||||||
|
funlen:
|
||||||
nestif:
|
lines: 80
|
||||||
min-complexity: 4
|
statements: 50
|
||||||
|
cyclop:
|
||||||
nlreturn:
|
max-complexity: 15
|
||||||
block-size: 2
|
dupl:
|
||||||
|
threshold: 100
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: var-naming
|
|
||||||
arguments:
|
|
||||||
- []
|
|
||||||
- []
|
|
||||||
- "upperCaseConst=true"
|
|
||||||
|
|
||||||
tagliatelle:
|
|
||||||
case:
|
|
||||||
rules:
|
|
||||||
json: snake
|
|
||||||
yaml: snake
|
|
||||||
xml: snake
|
|
||||||
bson: snake
|
|
||||||
|
|
||||||
testifylint:
|
|
||||||
enable-all: true
|
|
||||||
|
|
||||||
usetesting: {}
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
exclude-rules:
|
|
||||||
# Exclude unused parameter warnings for cobra command signatures
|
|
||||||
- text: "parameter '(args|cmd)' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow ALL_CAPS constant names
|
|
||||||
- text: "don't use ALL_CAPS in Go names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow snake_case JSON tags for external API compatibility
|
|
||||||
- path: "internal/types/ris.go"
|
|
||||||
linters:
|
|
||||||
- tagliatelle
|
|
||||||
|
|
||||||
# Allow snake_case JSON tags for database models
|
|
||||||
- path: "internal/database/models.go"
|
|
||||||
linters:
|
|
||||||
- tagliatelle
|
|
||||||
|
|
||||||
# Allow generic package name for types that define data structures
|
|
||||||
- path: "internal/types/"
|
|
||||||
text: "avoid meaningless package names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow globals in the globals package (by design)
|
|
||||||
- path: "internal/globals/"
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
# Allow globals in main (Version/Buildarch set by ldflags)
|
|
||||||
- path: "cmd/"
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
# Allow blank imports for driver registration
|
|
||||||
- text: "blank-imports"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow unused fx.Lifecycle parameters (required by fx signature)
|
|
||||||
- text: "parameter 'lc' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow unused context parameters in fx hooks
|
|
||||||
- text: "parameter 'ctx' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|||||||
+42
-27
@@ -1,18 +1,23 @@
|
|||||||
# Lint stage
|
# Lint stage
|
||||||
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
|
# Same image as Dockerfile.lint: change both pins together.
|
||||||
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||||
|
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 . .
|
||||||
|
|
||||||
|
# Tells script/lint it is inside a container, so it runs the linter.
|
||||||
|
ENV container=docker
|
||||||
|
|
||||||
# Run formatting check and linter
|
# Run formatting check and linter
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
@@ -24,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 . .
|
||||||
@@ -45,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
|
||||||
@@ -57,22 +60,34 @@ RUN apk add --no-cache \
|
|||||||
vips \
|
vips \
|
||||||
libheif \
|
libheif \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata
|
tzdata \
|
||||||
|
su-exec
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /pixad /usr/local/bin/pixad
|
COPY --from=builder /pixad /usr/local/bin/pixad
|
||||||
|
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create non-root user, config directory, and data directory
|
# 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
|
||||||
|
|
||||||
# Copy default config (edit signing_key before use)
|
# No USER: the entrypoint must start as root to give a bind-mounted
|
||||||
COPY config.example.yml /etc/pixa/config.yml
|
# /var/lib/pixa to pixad; it then runs the server as pixad.
|
||||||
|
|
||||||
USER pixad
|
|
||||||
WORKDIR /var/lib/pixa
|
WORKDIR /var/lib/pixa
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/pixad", "--config", "/etc/pixa/config.yml"]
|
# Shell form so the probe follows PORT; a port set only in a mounted
|
||||||
|
# config file is not seen here.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --spider -q "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
|
||||||
|
|
||||||
|
# Settings come from PORT and the PIXA_ environment variables; only
|
||||||
|
# PIXA_SIGNING_KEY is required. A config file mounted at
|
||||||
|
# /etc/pixa/config.yml is optional and is read when present.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
|
||||||
|
# which is never installed on the host. Pinned to the same image as the
|
||||||
|
# Dockerfile lint stage: change both pins together, or the two run
|
||||||
|
# different linter versions.
|
||||||
|
#
|
||||||
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
||||||
|
# this image needs the same C libraries the build does. script/bootstrap
|
||||||
|
# installs them and downloads the Go modules. Only script/, go.mod and
|
||||||
|
# go.sum are copied first; they settle this layer's result, so it may
|
||||||
|
# safely be reused between runs.
|
||||||
|
COPY script/ ./script/
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Tells script/lint it is inside a container, so it runs the linter.
|
||||||
|
ENV container=docker
|
||||||
|
|
||||||
|
# script/lint passes a different CACHEBUST on every run, and BuildKit
|
||||||
|
# keys every RUN after this ARG on its value, so the lint step always
|
||||||
|
# runs instead of returning a cached success that linted nothing.
|
||||||
|
#
|
||||||
|
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
|
||||||
|
# tmpfs that is discarded after the step. Written into the layer, they
|
||||||
|
# would pile up as build cache on every run, since no later run, with
|
||||||
|
# its new CACHEBUST, can reuse that layer.
|
||||||
|
ARG CACHEBUST
|
||||||
|
RUN --mount=type=tmpfs,target=/root/.cache script/lint
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
@@ -10,7 +10,7 @@ ifdef HAS_PKGCONFIG
|
|||||||
NIX_RUN_PREFIX =
|
NIX_RUN_PREFIX =
|
||||||
NIX_RUN_SUFFIX =
|
NIX_RUN_SUFFIX =
|
||||||
else
|
else
|
||||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run '
|
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run '
|
||||||
NIX_RUN_SUFFIX = '
|
NIX_RUN_SUFFIX = '
|
||||||
endif
|
endif
|
||||||
|
|
||||||
@@ -54,6 +54,11 @@ clean:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
# Build the image, start it, and wait for its healthcheck (needs Docker;
|
||||||
|
# not part of check)
|
||||||
|
docker-smoke:
|
||||||
|
@script/docker-smoke
|
||||||
|
|
||||||
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
|
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
|
||||||
docker-versioned:
|
docker-versioned:
|
||||||
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
||||||
|
|||||||
@@ -15,21 +15,56 @@ git clone https://git.eeqj.de/sneak/pixa.git
|
|||||||
cd pixa
|
cd pixa
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# run with a config file
|
# run with a config file: copy the example and set a real signing key
|
||||||
./bin/pixad --config config.example.yml
|
# (the example placeholder is refused at startup), e.g. with
|
||||||
|
# openssl rand -base64 32
|
||||||
|
cp config.example.yml config.yml
|
||||||
|
$EDITOR config.yml # replace the signing_key placeholder
|
||||||
|
./bin/pixad --config config.yml
|
||||||
|
|
||||||
# or build and run via Docker
|
# or build and run via Docker
|
||||||
make docker
|
make docker
|
||||||
docker run -p 8080:8080 pixad:latest
|
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A container takes its settings from environment variables (see
|
||||||
|
Configuration below for the list). Only `PIXA_SIGNING_KEY` is required; if
|
||||||
|
it is unset the container exits at startup naming the variable. Everything
|
||||||
|
else has a built-in default. A config file mounted at `/etc/pixa/config.yml`
|
||||||
|
is optional: it is read when present, and an environment variable wins over
|
||||||
|
the same setting in it.
|
||||||
|
|
||||||
|
## Running under upaas
|
||||||
|
|
||||||
|
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||||
|
|
||||||
|
- **Port:** pixa listens on container port `8080`.
|
||||||
|
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its
|
||||||
|
database and cache. Creating the host directory when it is missing is
|
||||||
|
upaas's job, tracked in https://git.eeqj.de/sneak/upaas/issues/235.
|
||||||
|
- **Environment variables:**
|
||||||
|
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs
|
||||||
|
and login, 32+ characters, for example from
|
||||||
|
`openssl rand -base64 32`
|
||||||
|
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
||||||
|
comma-separated
|
||||||
|
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
||||||
|
default 75% of free space
|
||||||
|
- the rest are in the table under Configuration below
|
||||||
|
- **Health check:** the image's `HEALTHCHECK` requests
|
||||||
|
`/.well-known/healthcheck.json`. upaas reads the container's health 60
|
||||||
|
seconds after a deploy and marks the deploy failed unless it is
|
||||||
|
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
|
||||||
|
port changed only in a mounted config file is not seen by it: change
|
||||||
|
the port with `PORT`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Image-heavy web applications need a fast, caching reverse proxy that
|
Image-heavy web applications need a fast, caching reverse proxy that
|
||||||
can resize and transcode images on the fly. pixa fills that role as a
|
can resize and transcode images on the fly. pixa fills that role as a
|
||||||
single, self-contained binary with no external runtime dependencies
|
single, self-contained binary with no external runtime dependencies
|
||||||
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
||||||
prevent abuse, and whitelisted source hosts for open access.
|
prevent abuse, and allowlisted source hosts for open access.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -46,7 +81,10 @@ prevent abuse, and whitelisted 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
|
||||||
|
|
||||||
@@ -56,26 +94,68 @@ In-process caching of request-to-output mappings targets 1-5k r/s.
|
|||||||
|
|
||||||
Images are only fetched from origins using TLS with valid certificates.
|
Images are only fetched from origins using TLS with valid certificates.
|
||||||
|
|
||||||
|
A request whose query string cannot be decoded, or gives any parameter more
|
||||||
|
than once, is refused with 400.
|
||||||
|
|
||||||
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
|
|
||||||
|
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
||||||
|
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
|
||||||
|
`max-age` is the whole seconds left until then, at most one year, so no browser
|
||||||
|
or proxy cache keeps the image after pixa would refuse the URL. A URL with no
|
||||||
|
expiry gets one year. `immutable` only stops a client revalidating while its
|
||||||
|
copy is fresh.
|
||||||
|
|
||||||
|
The login form (`POST /`) is limited to 5 attempts per minute per client
|
||||||
|
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||||
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
|
address comes from `X-Forwarded-For` only when the address pixa sees for
|
||||||
|
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
||||||
|
users behind the proxy are counted as one client. That address is not always
|
||||||
|
the proxy's own: a proxy on the Docker host that connects to pixa over
|
||||||
|
`127.0.0.1` is seen as the gateway of the container's Docker network, such as
|
||||||
|
`172.17.0.1` on the default bridge, and one that connects through another of the
|
||||||
|
host's addresses is seen with that address. To be sure, read it as `remoteIP` in
|
||||||
|
pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies`
|
||||||
|
under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges),
|
||||||
|
a client with a private address can choose the address it is counted by through
|
||||||
|
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
|
||||||
|
because its own address is trusted too. Setting `trusted_proxies` to only the
|
||||||
|
address pixa sees for requests that come through the proxy closes this.
|
||||||
|
|
||||||
|
### Image Metadata
|
||||||
|
|
||||||
|
pixa decodes and re-encodes every image it serves, and removes all metadata from
|
||||||
|
the output: EXIF (GPS position, camera make, model and serial number, capture
|
||||||
|
time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be
|
||||||
|
turned off.
|
||||||
|
|
||||||
|
- The `orig` format means the source's own format, not the source's bytes: an
|
||||||
|
`orig` image is re-encoded and stripped like any other.
|
||||||
|
- An image with an EXIF orientation is turned upright first, so it displays the
|
||||||
|
same without the tag; a requested size applies to the upright image.
|
||||||
|
- An image with an ICC profile is converted to sRGB first, since clients show an
|
||||||
|
image with no profile as sRGB. Colours outside sRGB, such as the most
|
||||||
|
saturated ones in a Display P3 photo, are clipped.
|
||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
Source hosts may be whitelisted in the configuration. Non-whitelisted
|
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
||||||
hosts require an HMAC-SHA256 signature.
|
hosts require an HMAC-SHA256 signature.
|
||||||
|
|
||||||
#### Signature Specification
|
#### Signature Specification
|
||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
||||||
prevent replay attacks. Signatures are **exact match only**: every
|
prevent replay attacks. Signatures are **exact match only**: every
|
||||||
component (host, path, query, dimensions, format, expiration) must
|
component (host, path, query, dimensions, format, expiration, quality,
|
||||||
match exactly what was signed. No suffix matching, wildcard matching,
|
fit) must match exactly what was signed. No suffix matching, wildcard
|
||||||
or partial matching is supported.
|
matching, or partial matching is supported.
|
||||||
|
|
||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
```
|
```
|
||||||
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
@@ -86,20 +166,39 @@ Where:
|
|||||||
- `width` — requested width in pixels, `0` for original
|
- `width` — requested width in pixels, `0` for original
|
||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
- `expiration` — Unix timestamp when signature expires
|
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when
|
||||||
|
the signature expires; a request whose `exp` is not a whole number, an
|
||||||
|
empty `exp=` included, is refused with 400
|
||||||
|
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100,
|
||||||
|
or `85` when the URL has no `q`; a request whose `q` is anything else is
|
||||||
|
refused with 400
|
||||||
|
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
||||||
|
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
||||||
|
anything else, an empty `fit=` included, is refused with 400
|
||||||
|
|
||||||
**Example:** resize
|
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
|
||||||
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
||||||
expiration 1704067200:
|
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`
|
`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`
|
||||||
|
|
||||||
**Whitelist patterns:**
|
For the same image at quality 40 with fit `contain`, the input ends in
|
||||||
|
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`,
|
||||||
|
and the URL is
|
||||||
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain`.
|
||||||
|
|
||||||
|
**Allowlist patterns:**
|
||||||
|
|
||||||
- **Exact match**: `cdn.example.com` — matches only that host
|
- **Exact match**: `cdn.example.com` — matches only that host
|
||||||
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
||||||
@@ -107,14 +206,111 @@ expiration 1704067200:
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Configured via YAML file (`--config`). Key settings:
|
Every setting can be given as an environment variable, in a YAML config
|
||||||
|
file (`--config`), or both. A variable present in the environment wins over
|
||||||
|
the file, even when it is empty, and the file wins over the built-in
|
||||||
|
default. The one exception is a variable named in the file's `env:` section:
|
||||||
|
it is set while the file loads, so it overrides both the environment the
|
||||||
|
process was started with and the file's own key. A variable's value is
|
||||||
|
parsed as the same text in the file would be. The three lists take
|
||||||
|
comma-separated entries, with the spaces around each trimmed; an empty
|
||||||
|
variable is an empty list. A value that does not parse or is invalid aborts
|
||||||
|
startup, naming the variable. A variable whose name starts with `PIXA_` but
|
||||||
|
is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts
|
||||||
|
startup naming it, as an unknown config key does. The one other accepted
|
||||||
|
name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The
|
||||||
|
variables set by the file's `env:` section are checked the same way.
|
||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
| Variable | Config key | Meaning |
|
||||||
- `source_host_whitelist` — list of allowed upstream hosts
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
| `PIXA_SIGNING_KEY` | `signing_key` | Required: secret for signed and encrypted URLs and login, 32+ characters |
|
||||||
- `upstream_max_response_size` — max origin response size
|
| `PORT` | `port` | Port to listen on; default `8080` |
|
||||||
- `downstream_timeout` — client response timeout
|
| `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
|
||||||
|
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
|
||||||
|
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of free space |
|
||||||
|
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
|
||||||
|
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
|
||||||
|
| `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_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||||
|
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
||||||
|
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
||||||
|
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||||
|
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||||
|
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||||
|
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
|
||||||
|
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
||||||
|
| `PIXA_METRICS_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_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
|
||||||
|
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Answer image requests with 503; the health check stays 200; default `false` |
|
||||||
|
|
||||||
|
Key settings in more detail:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
||||||
|
added to the always-enforced built-in ranges (loopback, private,
|
||||||
|
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
|
||||||
|
aborts startup
|
||||||
|
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front
|
||||||
|
of pixa. `X-Forwarded-For` is believed only when the direct peer falls
|
||||||
|
inside one of these ranges; the logged and login-recorded client
|
||||||
|
address is then the rightmost forwarded entry that is not itself a
|
||||||
|
trusted proxy. Otherwise the direct peer address is used and the header
|
||||||
|
is ignored, so a client connecting directly from an address outside
|
||||||
|
these ranges cannot spoof its address.
|
||||||
|
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
||||||
|
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
||||||
|
one, and an explicit list replaces the default. An invalid CIDR aborts
|
||||||
|
startup. Set this to the address pixa sees for requests that come through
|
||||||
|
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
|
||||||
|
to trust nothing else (see the login limit under Routes). For a proxy on
|
||||||
|
the Docker host that connects to pixa over `127.0.0.1`, that address is the
|
||||||
|
gateway of the container's Docker network (`172.17.0.1` on the default
|
||||||
|
bridge), not the proxy's own address; a proxy that connects through another of
|
||||||
|
the host's addresses is seen with that address. To be sure which address it
|
||||||
|
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
||||||
|
through the proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream
|
||||||
|
host, as a duration such as `30s` (the default) or `2m`
|
||||||
|
- `upstream_max_response_size` — largest upstream response accepted, in
|
||||||
|
bytes; default `52428800` (50 MiB). It also limits the image data pixa
|
||||||
|
decodes
|
||||||
|
- `downstream_timeout` — time allowed for answering one client request, as a
|
||||||
|
duration; default `60s`. The upstream fetch counts toward it, and so do the
|
||||||
|
waits for an upstream connection and for a processing slot (up to 10 seconds
|
||||||
|
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
||||||
- `signing_key` — HMAC secret for URL signatures
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||||
|
disk cache entirely; omitted defaults to 75% of the free space on
|
||||||
|
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
||||||
|
- `upstream_connections` — the most connections to upstream hosts at once, all
|
||||||
|
hosts together, on top of `upstream_connections_per_host`; default `64`. A
|
||||||
|
fetch holds its connection until its image has been processed. A fetch that
|
||||||
|
finds all of them in use waits up to 10 seconds for one to free up; if none
|
||||||
|
does, and `downstream_timeout` has not ended first, the request is answered
|
||||||
|
503 with the error `server busy, try again later`
|
||||||
|
- `max_concurrent_processing` — the most images decoded and encoded at once;
|
||||||
|
default the number of CPUs pixa can use (`GOMAXPROCS`), which follows a
|
||||||
|
container's CPU limit. A request that finds all of them in use waits up to 10
|
||||||
|
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||||
|
ended first, it is answered 503 the same way
|
||||||
|
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||||
|
`/v1/e/`) answer every request with 503, a `Retry-After` header and a JSON
|
||||||
|
error body. The health check (`/.well-known/healthcheck.json`) still answers
|
||||||
|
200 and reports `"maintenance_mode": true`. It stays 200 because the image's
|
||||||
|
Docker `HEALTHCHECK` requests it: a 503 there would make the container
|
||||||
|
unhealthy, and upaas marks a deploy failed when its container is unhealthy.
|
||||||
|
The login and URL generator pages and `/metrics` keep working
|
||||||
|
|
||||||
See `config.example.yml` for all options with defaults.
|
See `config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
@@ -141,11 +337,13 @@ them. We provide:
|
|||||||
(bootstrap, then install-precommit)
|
(bootstrap, then install-precommit)
|
||||||
- `script/projectname` — output the project name ("pixa")
|
- `script/projectname` — output the project name ("pixa")
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/lint` — run golangci-lint
|
- `script/lint` — run golangci-lint, always in a container (builds
|
||||||
|
`Dockerfile.lint` when run outside one)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
|
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
||||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||||
runs the checks, so a green build implies a green repo)
|
runs the checks, so a green build implies a green repo)
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
|
|||||||
@@ -1,31 +1,333 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch (from `main`)
|
* branch per issue from `next`
|
||||||
* do the work in Next Step
|
* do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
* move Next Step to the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
* move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
* open a PR based on `next`
|
||||||
|
* an independent reviewer who did not write the change gates it
|
||||||
|
* the manager squash-merges the PR into `next` once review passes
|
||||||
|
* `next` stays green and mergeable to `main` at any time; only the owner
|
||||||
|
merges `next` into `main`, via the single milestone PR
|
||||||
* push
|
* push
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags exist. main (6b4a1d7, 2026-04-07) is current with
|
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work
|
||||||
origin; recent work extracted the internal/magic and internal/allowlist
|
lands on `next`, and `main` receives only the milestone PR that the
|
||||||
packages. The 10 gosec findings from the 2026-07-06 morning survey are
|
owner merges. `next` is at the canonical `golangci-lint` v2.12.2 config
|
||||||
NOT fixed on main: the newest gosec/lint fix commits are from
|
and is green. Recent work extracted the internal/magic,
|
||||||
2026-02-25 (85729d9, ce6db76) and nothing since touches gosec, so those
|
internal/allowlist, internal/httpfetcher, and internal/signature
|
||||||
findings remain open.
|
packages. The gosec findings from the 2026-07-06 survey are resolved.
|
||||||
|
The disk cache is now size-bounded with LRU eviction
|
||||||
|
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Fix the 10 open gosec lint findings so make check passes on main again
|
P2: security: referer blacklist
|
||||||
(main must always be green; no gosec fix commits have landed since
|
|
||||||
2026-02-25). No blanket suppressions; fix or justify each finding
|
|
||||||
individually.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
|
||||||
|
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
|
||||||
|
gives the directory and everything in it to `pixad` when the directory or one
|
||||||
|
of its top-level entries belongs to another user or group, sets its mode to
|
||||||
|
`750`, then runs the server as `pixad`; data left by an earlier run under
|
||||||
|
another uid is taken over this way; "Running under upaas" in `README.md` no
|
||||||
|
longer tells the operator to create or chown the host directory.
|
||||||
|
- 2026-09-29 variant content types kept in memory (closes #70):
|
||||||
|
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
|
||||||
|
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
|
||||||
|
`GetVariant` after it reads a `.meta` file, where a type `StoreVariant` added
|
||||||
|
meanwhile is kept over the one read, and never with the
|
||||||
|
`application/octet-stream` served for a variant without one; for a variant it
|
||||||
|
holds, `GetVariant` skips the `.meta` read, still opening the variant file and
|
||||||
|
taking the size from it; eviction removes the entry before deleting the files,
|
||||||
|
and `GetVariant` removes it when the file will not open; the cap is a
|
||||||
|
constant, not a setting; the unused `variantMeta` type is gone; `README.md`
|
||||||
|
describes it.
|
||||||
|
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
||||||
|
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
||||||
|
`Retry-After` header and the JSON error body, from one middleware in
|
||||||
|
`internal/server/routes.go`; the health check stays 200 and reports
|
||||||
|
`maintenance_mode`, as the image's Docker `HEALTHCHECK` requests it and upaas
|
||||||
|
marks a deploy failed when its container is unhealthy; the login and URL
|
||||||
|
generator pages and `/metrics` keep working; documented in `README.md` and
|
||||||
|
`config.example.yml`.
|
||||||
|
- 2026-09-29 bound concurrent image processing and upstream fetches (closes
|
||||||
|
#64): `max_concurrent_processing` (default the number of CPUs pixa can use)
|
||||||
|
limits the images decoded and encoded at once, and `upstream_connections`
|
||||||
|
(default 64) the connections to all upstream hosts together, on top of
|
||||||
|
`upstream_connections_per_host`; a fetch holds its connection until its image
|
||||||
|
has been processed, and a request whose source is cached reads it only once it
|
||||||
|
has a processing slot; a request that finds either limit reached waits up to
|
||||||
|
10 seconds for a free one, then gets 503 `server busy, try again later`;
|
||||||
|
libvips runs one worker thread per image with its operation cache off;
|
||||||
|
documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
|
||||||
|
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
|
||||||
|
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
|
||||||
|
`apk add` lines, so the build dependencies are listed in one place;
|
||||||
|
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
|
||||||
|
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
|
||||||
|
`ARG VERSION` sits just above the build, so a new version reruns neither
|
||||||
|
`script/bootstrap` nor the tests.
|
||||||
|
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
|
||||||
|
migration files moved, contents unchanged, from `internal/database/schema/`
|
||||||
|
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
|
||||||
|
`internal/db/migrations` package embeds them and `internal/database` reads
|
||||||
|
them through its `FS()`; the `internal/database` package itself stays; the
|
||||||
|
version still comes from the filename prefix, so a database that has recorded
|
||||||
|
versions 0 and 1 runs neither again.
|
||||||
|
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
||||||
|
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
||||||
|
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
||||||
|
requests that come through the proxy, which the request log shows as
|
||||||
|
`remoteIP` while it is not trusted; for a proxy on the Docker host that
|
||||||
|
connects over `127.0.0.1` that is the Docker network's gateway, not the
|
||||||
|
proxy's own address; the signature section says `sig` is base64url with the
|
||||||
|
`=` padding kept, and gives the example's `sig` for a stated signing key.
|
||||||
|
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
|
||||||
|
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
|
||||||
|
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
|
||||||
|
is not owned on the host by a person's login account; the first-run step of
|
||||||
|
"Running under upaas" in `README.md` names the uid and gid.
|
||||||
|
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
|
||||||
|
routes build `Cache-Control` from the request's `Expires`, which an encrypted
|
||||||
|
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
|
||||||
|
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
|
||||||
|
that is sooner, never negative; an allowlisted host's URL that has an `exp`
|
||||||
|
follows it too; `immutable` stays, as freshness now ends at the expiry;
|
||||||
|
documented in `README.md`.
|
||||||
|
- 2026-09-28 add the four settings `README.md` documented but pixa did not
|
||||||
|
have, which aborted startup as unknown keys (closes #61):
|
||||||
|
`access_control_allow_origin` (default `*`, the CORS origin),
|
||||||
|
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
|
||||||
|
(default 50 MiB) and `downstream_timeout` (default `60s`, both the
|
||||||
|
server's write timeout and the per-request timeout); each has a
|
||||||
|
`PIXA_` variable; durations are positive Go duration strings, the size a
|
||||||
|
whole number of bytes up to 1 GiB, the origin `*` or one `http` or
|
||||||
|
`https` origin as `README.md` describes it; an invalid value
|
||||||
|
aborts startup naming the key and the value; documented in
|
||||||
|
`config.example.yml` and `README.md`.
|
||||||
|
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
|
||||||
|
counts the cached source images and processed variants (`source_content`
|
||||||
|
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
|
||||||
|
instead of reading `request_cache` and `output_content`, which nothing
|
||||||
|
writes; those two tables are left in the schema; a disabled disk cache
|
||||||
|
reports no items and no size. A hit is counted even when the request
|
||||||
|
context has ended. A miss is counted after it is served or fails, also
|
||||||
|
when the request context has ended by then, with the bytes it read from
|
||||||
|
upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move,
|
||||||
|
including for an upstream body that fails partway or a fetched source
|
||||||
|
that then fails the magic byte check; `transform_count` counts each image
|
||||||
|
the image processor transcodes.
|
||||||
|
- 2026-09-28 strip metadata from processed images (closes #82): every output is
|
||||||
|
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
|
||||||
|
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
||||||
|
worked out) and, when it has an ICC profile, converted to sRGB; the `orig`
|
||||||
|
format is re-encoded and stripped like any other, as pixa never serves the
|
||||||
|
source bytes; there is no setting to keep metadata; documented in `README.md`.
|
||||||
|
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
|
||||||
|
attempts per minute per client address, and an attempt over the limit is
|
||||||
|
refused with 429 and a `Retry-After` header; the address is the one
|
||||||
|
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
|
||||||
|
counted by its /64, and an IPv4-mapped address as the IPv4 address it
|
||||||
|
carries; the limit is a `RateLimit` middleware in `internal/middleware` on
|
||||||
|
`github.com/go-chi/httprate`, which the image routes can reuse; the library
|
||||||
|
keeps counts for the current and the previous minute only; documented in
|
||||||
|
`README.md`.
|
||||||
|
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
|
||||||
|
cache errors (closes #72): an `exp` in the URL that is not a whole
|
||||||
|
number, an empty `exp=` included, is a 400 naming `exp` and the value,
|
||||||
|
instead of being ignored and answered with 401 as if the URL had no
|
||||||
|
`exp`; only an `exp` missing from the URL is unchanged; `README.md` says
|
||||||
|
so where it documents `exp`. A failed variant `.meta` write, source
|
||||||
|
metadata JSON write, `Stats` count query, stats counter update, negative
|
||||||
|
cache write or expired negative cache delete is now logged at `warn`
|
||||||
|
with the path or key and the error, and stays non-fatal.
|
||||||
|
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a
|
||||||
|
`fit` in the URL with an empty value (`fit=`) is a 400 naming `fit`,
|
||||||
|
instead of being served as `cover` and verified against a signature
|
||||||
|
made for `cover`; only a `fit` missing from the URL is still `cover`;
|
||||||
|
any other value still goes through the existing fit-mode check;
|
||||||
|
`README.md` says so where it documents `fit`.
|
||||||
|
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q`
|
||||||
|
that is not a whole number from 1 to 100, an empty `q` included, is a
|
||||||
|
400 naming `q` and the value, instead of being served at the default
|
||||||
|
85; the route reads `q` with the generator's quality check
|
||||||
|
(`parseFormInt` with `minQuality` and `maxQuality`); only a `q` missing
|
||||||
|
from the URL is still 85; a query string that cannot be decoded, such
|
||||||
|
as `q=80%`, is a 400 showing it; any query parameter given more than
|
||||||
|
once (`q`, `fit`, `sig`, `exp` alike) is a 400 naming it, so none is
|
||||||
|
read from its first value only; `README.md` states the range and both
|
||||||
|
query-string rules.
|
||||||
|
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes
|
||||||
|
#133): a variable whose name starts with `PIXA_` but is neither a
|
||||||
|
setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as
|
||||||
|
an unknown config key does, and `PIXA_PORT` is named with a pointer to
|
||||||
|
`PORT`; the check runs after the config file loads, so the variables
|
||||||
|
the file's `env:` section sets are checked too; documented in
|
||||||
|
`README.md`.
|
||||||
|
- 2026-09-28 start on a fresh upaas volume (closes #129): the image
|
||||||
|
starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
|
||||||
|
does not own it (`deploy/docker-entrypoint.sh`), then runs the server
|
||||||
|
as `pixad` through `su-exec`, so a root-owned host directory
|
||||||
|
bind-mounted there no longer stops the container at startup;
|
||||||
|
`README.md` gains a "Running under upaas" section.
|
||||||
|
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` +
|
||||||
|
`script/lint` (closes #104): `make lint` calls `script/lint`, the only
|
||||||
|
way the linter is run; inside a container (both Dockerfiles set
|
||||||
|
`container=docker`) it runs `golangci-lint`, anywhere else it builds the
|
||||||
|
hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again;
|
||||||
|
the `Dockerfile` lint stage runs `make lint`; no host or nix-shell
|
||||||
|
`golangci-lint` path remains (`script/bootstrap` installs no linter);
|
||||||
|
a per-run `CACHEBUST` build-arg keeps the lint step from being served
|
||||||
|
from cache, and a tmpfs mount on that step keeps Go's and
|
||||||
|
golangci-lint's caches out of its layer, so a run leaves no large build
|
||||||
|
cache behind; `golangci-lint config verify` stays out, as it fetches its
|
||||||
|
schema over an unpinned live HTTPS call
|
||||||
|
- 2026-09-28 every setting as an environment variable (closes #128, also
|
||||||
|
covers #99): each config key can be set by `PIXA_` plus the key in upper
|
||||||
|
case (`.` written as `_`), and the port by `PORT`; a variable present in
|
||||||
|
the environment, even empty, wins over the config file, which wins over
|
||||||
|
the default; the typed getters read the variable first, so every existing
|
||||||
|
check applies to it and a bad value aborts startup naming the variable;
|
||||||
|
lists are comma-separated, and an empty variable (or `""` in the file) is
|
||||||
|
an empty list; the Docker image no longer bakes in `config.docker.yml` or
|
||||||
|
passes `--config`, and its `HEALTHCHECK` probes `PORT` (default `8080`);
|
||||||
|
the config file is looked for under `/etc/pixa` and `~/.config/pixa`
|
||||||
|
instead of the daemon name `pixad`; documented in `README.md` and
|
||||||
|
`config.example.yml`.
|
||||||
|
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed
|
||||||
|
data is now `host:path:query:width:height:format:expiration:quality:fit`,
|
||||||
|
using `85` and `cover` when the URL has no `q` or `fit`, so one signed
|
||||||
|
URL can no longer be replayed across other quality and fit values to
|
||||||
|
create unauthorized cache entries and transcodes; the known-answer
|
||||||
|
vectors in `internal/signature/golden_test.go` and the README signature
|
||||||
|
specification describe the new format.
|
||||||
|
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
|
||||||
|
the runtime stage probing `/.well-known/healthcheck.json` with busybox
|
||||||
|
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
|
||||||
|
starts it with a throwaway `PIXA_SIGNING_KEY`, and passes only once
|
||||||
|
Docker reports it healthy within 30 seconds, removing the container on
|
||||||
|
exit; the Gitea workflow runs it after `script/cibuild`.
|
||||||
|
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
|
||||||
|
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
|
||||||
|
`net/netip` list parser as `blocked_networks` (an invalid entry aborts
|
||||||
|
startup naming the key and value; an omitted key defaults to the RFC 1918
|
||||||
|
private ranges, an explicitly empty list trusts no one, and an explicit
|
||||||
|
list replaces the default); a new
|
||||||
|
`internal/clientip` package resolves the client address by honoring
|
||||||
|
`X-Forwarded-For` only when the direct peer is a trusted proxy, walking
|
||||||
|
the chain right-to-left to the rightmost non-proxy entry, so a client
|
||||||
|
connecting directly cannot spoof its address; the resolved address is
|
||||||
|
stored in the request context by a new middleware and used by the
|
||||||
|
request-logging middleware and the login-attempt logs in place of the
|
||||||
|
raw peer address; documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-21 blocked networks configuration extending SSRF protection: a
|
||||||
|
`blocked_networks` config key taking a list of CIDRs (parsed with
|
||||||
|
`net/netip`, an invalid entry aborts startup naming the key and value),
|
||||||
|
added to the built-in blocklist rather than replacing it; the built-in
|
||||||
|
ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments
|
||||||
|
`192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96`
|
||||||
|
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
||||||
|
re-resolution so the DNS-rebinding window remains closed; documented in
|
||||||
|
`README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
|
||||||
|
route and the token generator (closes #62): `imgcache.ValidateDimension`
|
||||||
|
alone holds the `MaxDimension` bound and is used by the path parser, by
|
||||||
|
the new `ValidateImageRequest` (which also applies `ValidateFitMode`)
|
||||||
|
and by the generator; both the `/v1/image/` and `/v1/e/` routes call
|
||||||
|
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a
|
||||||
|
400 rather than an out-of-memory or a 500 from the processor; the URL
|
||||||
|
generator answers 400 naming the field for a `width` or `height` that is
|
||||||
|
not a number or fails the shared check, a `quality` that is not a number
|
||||||
|
from 1 to 100, a `ttl` that is not a number from 0 to the largest number
|
||||||
|
of seconds the expiry calculation can hold, or an unknown `fit`; an empty
|
||||||
|
`quality` is 85 and
|
||||||
|
an empty `ttl` never expires; the form's width and height inputs stop at
|
||||||
|
8192
|
||||||
|
- 2026-09-21 http.Server hardening (closes #92): added
|
||||||
|
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||||
|
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||||
|
existing timeouts and wired them onto the server; added a `LimitBody`
|
||||||
|
middleware capping the two form POST bodies (`POST /`, `POST /generate`)
|
||||||
|
at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF
|
||||||
|
middleware so an oversized body is refused as 413 rather than being read
|
||||||
|
as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged
|
||||||
|
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical
|
||||||
|
`.golangci.yml` (v2 schema, `default: all` minus six disabled
|
||||||
|
linters, `lll` 88, tests included): bumped the pinned
|
||||||
|
`golangci/golangci-lint:v2.12.2-alpine` image in `Dockerfile` and the
|
||||||
|
release-archive sha256 pins in `script/bootstrap`; fixed the findings
|
||||||
|
the stricter config surfaced (notably `paralleltest`, `wsl_v5`,
|
||||||
|
`goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`, `testpackage` —
|
||||||
|
white-box test files renamed to `*_internal_test.go`), including #55's
|
||||||
|
code absorbed after it merged, iterating the pinned linter to
|
||||||
|
`0 issues.`; no single finding total is substantiable, since
|
||||||
|
golangci-lint's `uniq-by-line` reveals new findings on a line as
|
||||||
|
others there are fixed — the documented re-measurements were 81 after
|
||||||
|
the #53 merge and 149 after the #55 merge; three behavior changes, so
|
||||||
|
not a pure no-op: `Cache.StoreVariant` now takes a `context.Context`
|
||||||
|
(`noctx`), so a cancelled request skips its best-effort accounting
|
||||||
|
row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and
|
||||||
|
leaked `.tmp-*.json` on failure, now fixed with explicit removals; and
|
||||||
|
the `signing_key` validation error text gained `value too short: `;
|
||||||
|
the eviction loop's uncancellable context is deferred to #102 under a
|
||||||
|
`//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep
|
||||||
|
the snake_case JSON wire/disk formats unchanged; `make check` green
|
||||||
|
- 2026-08-07 implement cache size management and eviction (closes
|
||||||
|
#51): new `cache_max_bytes` config key validated by the startup
|
||||||
|
framework (explicit values used exactly with no floor, `0` disables
|
||||||
|
the disk cache entirely, omitted defaults to max(75% of free space
|
||||||
|
on the filesystem containing `<state_dir>/cache/`, 500 MiB), logged
|
||||||
|
at startup); processed variants are now tracked in the database (a
|
||||||
|
new `variant_content` table and an LRU timestamp on `source_content`)
|
||||||
|
so total usage is two SUMs, never a directory scan on the hot path; a
|
||||||
|
background goroutine evicts globally least-recently-used entries
|
||||||
|
(variants and source blobs merged) to the limit, woken by a periodic
|
||||||
|
ticker and by write-pressure notifications from stores; a source
|
||||||
|
blob and ALL of its `source_metadata` references are deleted in one
|
||||||
|
transaction before the file is unlinked, so multi-referenced blobs
|
||||||
|
are never removed while referenced and rows never point at deleted
|
||||||
|
files; a startup and periodic reconciliation pass adopts untracked
|
||||||
|
variant files, drops rows for missing files, removes unreachable
|
||||||
|
source blobs, and sweeps stale temp files
|
||||||
|
- 2026-08-07 validate configuration on startup, fail fast on bad
|
||||||
|
config (closes #52): a config value that is set but unparseable or
|
||||||
|
invalid aborts startup naming the key and value (defaults apply only
|
||||||
|
to omitted keys), unknown config keys abort startup, a malformed
|
||||||
|
config file aborts instead of being skipped, and `state_dir` is
|
||||||
|
verified creatable and writable before the listener binds
|
||||||
|
- 2026-08-07 manual test pass of the auth and encrypted URL flows
|
||||||
|
against a locally built and running `pixad` (built from `main` at
|
||||||
|
`6573b9d`, port 18099, local throwaway config); all six checks
|
||||||
|
passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
|
||||||
|
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
|
||||||
|
page with `name="key"` password form
|
||||||
|
- [x] wrong key shows an error: POST `/` with `key=wrong-key`
|
||||||
|
returned HTTP 200 login page containing "Invalid signing key"
|
||||||
|
- [x] correct signing key shows the generator form: POST `/`
|
||||||
|
returned HTTP 303 to `/` with
|
||||||
|
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
|
||||||
|
GET `/` with that cookie rendered `Pixa - URL Generator` with the
|
||||||
|
`/generate` form and logout link
|
||||||
|
- [x] a generated encrypted URL serves the image: POST `/generate`
|
||||||
|
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
|
||||||
|
HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
|
||||||
|
61706 bytes
|
||||||
|
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
|
||||||
|
after 3 s returned HTTP 410 Gone with
|
||||||
|
`{"error":"URL has expired","status":410,...}`
|
||||||
|
- [x] logout redirects back to login: GET `/logout` returned HTTP
|
||||||
|
303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
|
||||||
|
subsequent GET `/` rendered the login form again
|
||||||
|
- 2026-08-07 fix the two remaining gosec findings (G124 in
|
||||||
|
internal/session): session cookies now always carry
|
||||||
|
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
|
||||||
|
`make check` green (closes #47)
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
||||||
@@ -48,22 +350,8 @@ individually.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- P0: manual test pass of the auth and encrypted URL flows, then
|
|
||||||
commit the checked-off results to TODO.md: visit / and see the login
|
|
||||||
form; wrong key shows an error; correct signing key shows the
|
|
||||||
generator form; a generated encrypted URL serves the image; an
|
|
||||||
expired URL (short TTL) returns 410; logout redirects back to login
|
|
||||||
- P0: implement cache size management and eviction so the disk cannot
|
|
||||||
fill up
|
|
||||||
- P0: validate configuration on startup, fail fast on bad config
|
|
||||||
- P1: implement blocked networks configuration to extend SSRF
|
|
||||||
protection
|
|
||||||
- P1: rate limit global concurrent upstream fetches to prevent
|
|
||||||
resource exhaustion
|
|
||||||
- P1: strip EXIF and other metadata from processed images (privacy)
|
|
||||||
- P2: security
|
- P2: security
|
||||||
- referer blacklist
|
- per-IP rate limiting on the image routes
|
||||||
- per-IP rate limiting
|
|
||||||
- per-origin rate limiting
|
- per-origin rate limiting
|
||||||
- P2: HTTP response handling
|
- P2: HTTP response handling
|
||||||
- Last-Modified headers
|
- Last-Modified headers
|
||||||
@@ -71,8 +359,6 @@ individually.
|
|||||||
- 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
|
|
||||||
- environment variable overrides
|
|
||||||
- YAML config file support
|
- YAML config file support
|
||||||
- P2: operational
|
- P2: operational
|
||||||
- optional Sentry error reporting
|
- optional Sentry error reporting
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,8 @@ func main() {
|
|||||||
|
|
||||||
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-2
@@ -1,33 +1,125 @@
|
|||||||
# Pixa Example Configuration
|
# Pixa Example Configuration
|
||||||
|
#
|
||||||
|
# Every key can also be set by an environment variable, which wins over
|
||||||
|
# this file: PIXA_ plus the key in upper case, with "." written as "_"
|
||||||
|
# (state_dir is PIXA_STATE_DIR, metrics.username is
|
||||||
|
# PIXA_METRICS_USERNAME). The one exception is port, which is set by
|
||||||
|
# PORT. In a variable, a list is comma-separated. A variable named in
|
||||||
|
# this file's env: section is set while the file loads, so it overrides
|
||||||
|
# both the environment the process was started with and this file's own
|
||||||
|
# key.
|
||||||
|
#
|
||||||
|
# Durations are Go duration strings such as 30s or 2m and must be
|
||||||
|
# positive; a bare number has no unit and aborts startup. Sizes are a
|
||||||
|
# whole number of bytes.
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
port: 8080
|
port: 8080
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
|
# While true, the image routes (/v1/image/ and /v1/e/) answer every request
|
||||||
|
# with 503 and a Retry-After header. The health check keeps answering 200 and
|
||||||
|
# reports maintenance_mode as true. It stays 200 because the image's Docker
|
||||||
|
# HEALTHCHECK requests it: a 503 there would make the container unhealthy, and
|
||||||
|
# upaas marks a deploy failed when its container is unhealthy.
|
||||||
maintenance_mode: false
|
maintenance_mode: false
|
||||||
|
|
||||||
# Data directory for SQLite database and cache files
|
# Data directory for SQLite database and cache files
|
||||||
state_dir: ./data
|
state_dir: ./data
|
||||||
|
|
||||||
# Image proxy settings
|
# Image proxy settings
|
||||||
# HMAC signing key for URL signatures (leave empty to require whitelist for all requests)
|
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
# Hosts that don't require signatures
|
# Hosts that don't require signatures
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
whitelist_hosts:
|
allowlist_hosts:
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
- sneak.berlin
|
- sneak.berlin
|
||||||
- github.com
|
- github.com
|
||||||
- user-images.githubusercontent.com
|
- user-images.githubusercontent.com
|
||||||
|
|
||||||
|
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
||||||
|
# SSRF protection. These are added to the always-enforced built-in ranges
|
||||||
|
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
|
||||||
|
# similar), never replacing them. Each entry must be a valid CIDR in IPv4
|
||||||
|
# or IPv6 form; an invalid entry aborts startup.
|
||||||
|
# blocked_networks:
|
||||||
|
# - 100.64.0.0/10
|
||||||
|
# - 2001:db8::/32
|
||||||
|
|
||||||
|
# CIDR ranges of the reverse proxies in front of pixa. X-Forwarded-For
|
||||||
|
# is believed only when the direct peer is inside one of these ranges;
|
||||||
|
# the client address in the access log and login records is then the
|
||||||
|
# rightmost forwarded entry that is not itself a trusted proxy. A client
|
||||||
|
# connecting directly (peer outside these ranges) cannot spoof its
|
||||||
|
# address: the header is ignored and the peer address is used. When
|
||||||
|
# omitted, this defaults to the RFC 1918 private ranges (10.0.0.0/8,
|
||||||
|
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
|
||||||
|
# a private network. An explicitly empty list ([]) trusts no one; an
|
||||||
|
# explicit list replaces the default. An invalid CIDR aborts startup.
|
||||||
|
# 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:
|
||||||
|
# - 10.0.0.0/8
|
||||||
|
# - 2001:db8::/32
|
||||||
|
|
||||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||||
allow_http: false
|
allow_http: false
|
||||||
|
|
||||||
# Maximum concurrent connections per upstream host (default: 20)
|
# Maximum concurrent connections per upstream host (default: 20)
|
||||||
upstream_connections_per_host: 20
|
upstream_connections_per_host: 20
|
||||||
|
|
||||||
|
# Maximum concurrent connections to all upstream hosts together, on top of
|
||||||
|
# the per-host limit (default: 64). A fetch holds its connection until its
|
||||||
|
# image has been processed. A fetch that finds none free waits up to 10
|
||||||
|
# seconds for one, and if none frees up the request is answered 503, unless
|
||||||
|
# downstream_timeout has ended first.
|
||||||
|
upstream_connections: 64
|
||||||
|
|
||||||
|
# Maximum number of images decoded and encoded at once (default: the
|
||||||
|
# number of CPUs pixa can use, which follows a container's CPU limit). A
|
||||||
|
# request that finds none free waits up to 10 seconds for one, and if none
|
||||||
|
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||||
|
# max_concurrent_processing: 4
|
||||||
|
|
||||||
|
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||||
|
upstream_fetch_timeout: 30s
|
||||||
|
|
||||||
|
# Largest upstream response accepted, in bytes, at most 1073741824
|
||||||
|
# (1 GiB) (default: 52428800, 50 MiB)
|
||||||
|
upstream_max_response_size: 52428800
|
||||||
|
|
||||||
|
# Time allowed for answering one client request (default: 60s). The
|
||||||
|
# upstream fetch counts toward it, and so do the waits for an upstream
|
||||||
|
# connection and for a processing slot (up to 10 seconds each), so keep it
|
||||||
|
# longer than upstream_fetch_timeout plus 20 seconds.
|
||||||
|
downstream_timeout: 60s
|
||||||
|
|
||||||
|
# The origin a browser lets read pixa's responses, sent as the CORS
|
||||||
|
# Access-Control-Allow-Origin header: "*" (the default) is any site;
|
||||||
|
# otherwise one http or https origin such as https://example.com, whose
|
||||||
|
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
||||||
|
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
||||||
|
# shortest form), with an optional port 1-65535 that has no leading zero
|
||||||
|
# and is not the scheme's default. Any other value, including another
|
||||||
|
# scheme such as a browser extension's, aborts startup.
|
||||||
|
access_control_allow_origin: "*"
|
||||||
|
|
||||||
|
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
||||||
|
# given; 0 disables the disk cache entirely (every request fetches and
|
||||||
|
# processes uncached). When omitted, the default is 75% of the free
|
||||||
|
# space on the filesystem containing <state_dir>/cache/ at startup,
|
||||||
|
# with a minimum of 500 MiB.
|
||||||
|
# cache_max_bytes: 10737418240
|
||||||
|
|
||||||
# Sentry error reporting (optional)
|
# Sentry error reporting (optional)
|
||||||
sentry_dsn: ""
|
sentry_dsn: ""
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
|
||||||
|
# root only to make /var/lib/pixa usable by pixad: a host directory
|
||||||
|
# bind-mounted there keeps its host owner, often root, and data from an
|
||||||
|
# earlier run may belong to another uid. The server itself always runs
|
||||||
|
# as pixad.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
mkdir -p /var/lib/pixa
|
||||||
|
# Only the directory and its top-level entries are checked, so a
|
||||||
|
# normal start does not walk the cache. -depth gives each directory
|
||||||
|
# to pixad after its contents, so a start stopped part way leaves
|
||||||
|
# something at the top for the next start to find; -h changes a
|
||||||
|
# symlink itself, never the file it points to.
|
||||||
|
if [ -n "$(find /var/lib/pixa -maxdepth 1 \( ! -user pixad -o ! -group pixad \))" ]; then
|
||||||
|
find /var/lib/pixa -depth -exec chown -h pixad:pixad {} +
|
||||||
|
fi
|
||||||
|
chmod 750 /var/lib/pixa
|
||||||
|
exec su-exec pixad /usr/local/bin/pixad "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -11,7 +11,10 @@ require (
|
|||||||
github.com/getsentry/sentry-go v0.40.0
|
github.com/getsentry/sentry-go v0.40.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
|
github.com/go-chi/httprate v0.16.0
|
||||||
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/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
|
||||||
@@ -90,6 +93,7 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
@@ -112,6 +116,7 @@ require (
|
|||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
|||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
|
||||||
|
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
@@ -175,6 +177,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||||
|
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||||
|
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
@@ -224,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=
|
||||||
@@ -249,6 +255,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -394,6 +402,10 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
|||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
|
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
type HostAllowList struct {
|
type HostAllowList struct {
|
||||||
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
||||||
exactHosts map[string]struct{}
|
exactHosts map[string]struct{}
|
||||||
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
|
// suffixHosts contains domain suffixes to match
|
||||||
|
// (e.g., ".example.com" matches "cdn.example.com")
|
||||||
suffixHosts []string
|
suffixHosts []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,104 +7,37 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/allowlist"
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHostAllowList_IsAllowed(t *testing.T) {
|
const (
|
||||||
tests := []struct {
|
testExactHost = "cdn.example.com"
|
||||||
|
testImageURL = "https://cdn.example.com/image.jpg"
|
||||||
|
testSuffix = ".example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
type isAllowedCase struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
testURL string
|
testURL string
|
||||||
want bool
|
want bool
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "exact match",
|
|
||||||
patterns: []string{"cdn.example.com"},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact match case insensitive",
|
|
||||||
patterns: []string{"CDN.Example.COM"},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact match not found",
|
|
||||||
patterns: []string{"cdn.example.com"},
|
|
||||||
testURL: "https://other.example.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match deep subdomain",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://cdn.images.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match apex domain",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match not found",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://notexample.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match partial not allowed",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://fakeexample.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns",
|
|
||||||
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
|
|
||||||
testURL: "https://photos.images.org/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty allow list",
|
|
||||||
patterns: []string{},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil url",
|
|
||||||
patterns: []string{"cdn.example.com"},
|
|
||||||
testURL: "",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "url with port",
|
|
||||||
patterns: []string{"cdn.example.com"},
|
|
||||||
testURL: "https://cdn.example.com:443/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace in patterns",
|
|
||||||
patterns: []string{" cdn.example.com ", " .other.com "},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runIsAllowedCases(t *testing.T, tests []isAllowedCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
w := allowlist.New(tt.patterns)
|
w := allowlist.New(tt.patterns)
|
||||||
|
|
||||||
var u *url.URL
|
var u *url.URL
|
||||||
|
|
||||||
if tt.testURL != "" {
|
if tt.testURL != "" {
|
||||||
var err error
|
parsed, err := url.Parse(tt.testURL)
|
||||||
u, err = url.Parse(tt.testURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to parse test URL: %v", err)
|
t.Fatalf("failed to parse test URL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
got := w.IsAllowed(u)
|
got := w.IsAllowed(u)
|
||||||
@@ -115,7 +48,101 @@ func TestHostAllowList_IsAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostAllowList_IsAllowed_ExactMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runIsAllowedCases(t, []isAllowedCase{
|
||||||
|
{
|
||||||
|
name: "exact match",
|
||||||
|
patterns: []string{testExactHost},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match case insensitive",
|
||||||
|
patterns: []string{"CDN.Example.COM"},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match not found",
|
||||||
|
patterns: []string{testExactHost},
|
||||||
|
testURL: "https://other.example.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple patterns",
|
||||||
|
patterns: []string{testExactHost, ".images.org", "static.test.net"},
|
||||||
|
testURL: "https://photos.images.org/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty allow list",
|
||||||
|
patterns: []string{},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil url",
|
||||||
|
patterns: []string{testExactHost},
|
||||||
|
testURL: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "url with port",
|
||||||
|
patterns: []string{testExactHost},
|
||||||
|
testURL: "https://cdn.example.com:443/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace in patterns",
|
||||||
|
patterns: []string{" cdn.example.com ", " .other.com "},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runIsAllowedCases(t, []isAllowedCase{
|
||||||
|
{
|
||||||
|
name: "suffix match",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match deep subdomain",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://cdn.images.example.com/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match apex domain",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://example.com/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match not found",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://notexample.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match partial not allowed",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://fakeexample.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestHostAllowList_IsEmpty(t *testing.T) {
|
func TestHostAllowList_IsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -145,6 +172,8 @@ func TestHostAllowList_IsEmpty(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
w := allowlist.New(tt.patterns)
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.IsEmpty(); got != tt.want {
|
if got := w.IsEmpty(); got != tt.want {
|
||||||
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
||||||
@@ -154,6 +183,8 @@ func TestHostAllowList_IsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHostAllowList_Count(t *testing.T) {
|
func TestHostAllowList_Count(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -183,6 +214,8 @@ func TestHostAllowList_Count(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
w := allowlist.New(tt.patterns)
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.Count(); got != tt.want {
|
if got := w.Count(); got != tt.want {
|
||||||
t.Errorf("Count() = %v, want %v", got, tt.want)
|
t.Errorf("Count() = %v, want %v", got, tt.want)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Package clientip resolves the real client IP address of an HTTP request
|
||||||
|
// when pixa runs behind a reverse proxy. Forwarding headers are believed
|
||||||
|
// only when the immediate peer is a configured trusted proxy, so an
|
||||||
|
// untrusted client cannot spoof its address by sending the header.
|
||||||
|
package clientip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForwardedForHeader is the request header carrying the proxy chain. It is
|
||||||
|
// honored only when the immediate peer is a trusted proxy.
|
||||||
|
const ForwardedForHeader = "X-Forwarded-For"
|
||||||
|
|
||||||
|
// Resolver determines the client IP of a request against a fixed set of
|
||||||
|
// trusted proxy networks.
|
||||||
|
type Resolver struct {
|
||||||
|
trusted []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResolver returns a Resolver that trusts forwarding headers only from
|
||||||
|
// peers inside the given CIDR ranges. A nil or empty list trusts no one,
|
||||||
|
// so the peer address is always used.
|
||||||
|
func NewResolver(trusted []netip.Prefix) *Resolver {
|
||||||
|
return &Resolver{trusted: trusted}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the client IP for a request whose direct peer is
|
||||||
|
// remoteAddr (a "host:port" string as in http.Request.RemoteAddr) and
|
||||||
|
// whose X-Forwarded-For header lines are forwardedFor (as returned by
|
||||||
|
// http.Header.Values). When the peer is not a trusted proxy, the peer
|
||||||
|
// address is returned and the header is ignored entirely. When the peer is
|
||||||
|
// trusted, the header is walked right to left and the first address that is
|
||||||
|
// not itself a trusted proxy is returned; this is the client the outermost
|
||||||
|
// trusted proxy observed, and entries an untrusted client may have prepended
|
||||||
|
// sit to its left and are never reached.
|
||||||
|
func (r *Resolver) Resolve(remoteAddr string, forwardedFor []string) string {
|
||||||
|
peer := hostOnly(remoteAddr)
|
||||||
|
|
||||||
|
peerAddr, err := netip.ParseAddr(peer)
|
||||||
|
if err != nil || !r.isTrusted(peerAddr) {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hop := range slices.Backward(forwardedForChain(forwardedFor)) {
|
||||||
|
hopAddr, err := netip.ParseAddr(hop)
|
||||||
|
if err != nil || r.isTrusted(hopAddr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return hopAddr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return peerAddr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTrusted reports whether addr falls inside one of the trusted proxy
|
||||||
|
// ranges. Addresses are unmapped first so an IPv4-mapped IPv6 form matches
|
||||||
|
// an IPv4 range, matching the fetcher's blocklist comparison.
|
||||||
|
func (r *Resolver) isTrusted(addr netip.Addr) bool {
|
||||||
|
if !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
unmapped := addr.Unmap()
|
||||||
|
|
||||||
|
return slices.ContainsFunc(r.trusted, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(unmapped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOnly strips the port from a "host:port" address. A value without a
|
||||||
|
// port (already a bare host) is returned unchanged.
|
||||||
|
func hostOnly(remoteAddr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return remoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedForChain flattens the comma-separated entries of every
|
||||||
|
// X-Forwarded-For header line into a single ordered, trimmed list.
|
||||||
|
func forwardedForChain(values []string) []string {
|
||||||
|
var chain []string
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
for part := range strings.SplitSeq(value, ",") {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed != "" {
|
||||||
|
chain = append(chain, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey is the private key type under which the resolved client IP is
|
||||||
|
// stored in a request context.
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
// WithClientIP returns a copy of ctx carrying the resolved client IP.
|
||||||
|
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKey{}, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromContext returns the resolved client IP stored in ctx, or an empty
|
||||||
|
// string if none was set.
|
||||||
|
func FromContext(ctx context.Context) string {
|
||||||
|
ip, _ := ctx.Value(contextKey{}).(string)
|
||||||
|
|
||||||
|
return ip
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package clientip_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addresses reused across the resolver cases.
|
||||||
|
const (
|
||||||
|
trustedRangeV4 = "10.0.0.0/8"
|
||||||
|
forwardedV4 = "203.0.113.7"
|
||||||
|
untrustedV4 = "198.51.100.9"
|
||||||
|
trustedPeer = "10.0.0.1:5000"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustPrefixes parses CIDR strings into prefixes for building a resolver.
|
||||||
|
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||||
|
|
||||||
|
for _, c := range cidrs {
|
||||||
|
p, err := netip.ParsePrefix(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes = append(prefixes, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolveCase struct {
|
||||||
|
name string
|
||||||
|
trusted []string
|
||||||
|
remoteAddr string
|
||||||
|
forwardedFor []string
|
||||||
|
want string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResolveCases runs each case against a resolver built from its trusted
|
||||||
|
// list and checks the resolved address.
|
||||||
|
func runResolveCases(t *testing.T, cases []resolveCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := clientip.NewResolver(mustPrefixes(t, tt.trusted...))
|
||||||
|
|
||||||
|
got := r.Resolve(tt.remoteAddr, tt.forwardedFor)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Resolve(%q, %v) = %q, want %q",
|
||||||
|
tt.remoteAddr, tt.forwardedFor, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolvePeerTrust covers the trust decision on the direct peer: a
|
||||||
|
// forwarded header is believed only from a trusted peer, and a client
|
||||||
|
// connecting directly cannot spoof its address.
|
||||||
|
func TestResolvePeerTrust(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runResolveCases(t, []resolveCase{
|
||||||
|
{
|
||||||
|
name: "trusted peer honors forwarded client",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "untrusted peer ignores forwarded header",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: untrustedV4 + ":33333",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: untrustedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spoofed chain from untrusted peer cannot influence result",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: untrustedV4 + ":33333",
|
||||||
|
forwardedFor: []string{"1.2.3.4, 10.9.9.9, 127.0.0.1"},
|
||||||
|
want: untrustedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty trusted list always uses peer",
|
||||||
|
trusted: nil,
|
||||||
|
remoteAddr: forwardedV4 + ":80",
|
||||||
|
forwardedFor: []string{"10.0.0.5"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted peer with no forwarded header uses peer",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: nil,
|
||||||
|
want: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable peer is returned unchanged",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: "garbage",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: "garbage",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveChainWalk covers walking the X-Forwarded-For chain from a
|
||||||
|
// trusted peer to the rightmost entry that is not itself a trusted proxy.
|
||||||
|
func TestResolveChainWalk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runResolveCases(t, []resolveCase{
|
||||||
|
{
|
||||||
|
name: "rightmost untrusted entry across a mixed chain",
|
||||||
|
trusted: []string{trustedRangeV4, "192.168.0.0/16"},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4 + ", 192.168.1.1, 10.0.0.2"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spoofed client behind a trusted proxy is not believed",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{"1.2.3.4, " + untrustedV4},
|
||||||
|
want: untrustedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain split across multiple header lines",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4, "10.0.0.2"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "garbage entries are skipped",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4 + ", not-an-ip"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all-trusted chain falls back to peer",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{"10.0.0.9, 10.0.0.2"},
|
||||||
|
want: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted IPv6 peer honors forwarded client",
|
||||||
|
trusted: []string{"2001:db8::/32"},
|
||||||
|
remoteAddr: "[2001:db8::1]:9000",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4-mapped peer matches IPv4 trusted range",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: "[::ffff:10.0.0.1]:5000",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := clientip.WithClientIP(t.Context(), forwardedV4)
|
||||||
|
if got := clientip.FromContext(ctx); got != forwardedV4 {
|
||||||
|
t.Errorf("FromContext = %q, want %q", got, forwardedV4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromContextAbsent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := clientip.FromContext(t.Context()); got != "" {
|
||||||
|
t.Errorf("FromContext with no value = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
|
||||||
|
// each CIDR is parsed into the resolved prefixes in order.
|
||||||
|
func TestBlockedNetworksParsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `blocked_networks:
|
||||||
|
- 203.0.113.0/24
|
||||||
|
- 2001:db8::/32
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid blocked_networks should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"203.0.113.0/24", "2001:db8::/32"}
|
||||||
|
if len(c.BlockedNetworks) != len(want) {
|
||||||
|
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, w := range want {
|
||||||
|
if got := c.BlockedNetworks[i].String(); got != w {
|
||||||
|
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
|
||||||
|
// operator list empty; the built-in defaults still apply in the fetcher.
|
||||||
|
func TestBlockedNetworksOmittedIsEmpty(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 len(c.BlockedNetworks) != 0 {
|
||||||
|
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
|
||||||
|
// startup with an error naming the key and the offending value.
|
||||||
|
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "not-a-cidr",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- not-a-cidr
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare-address-without-prefix",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- 10.0.0.1
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-entry",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- ""
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-string-entry",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- 42
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null-value",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Static errors returned by the stub free-space probes below.
|
||||||
|
var (
|
||||||
|
errTestStatfsFailed = errors.New("statfs failed")
|
||||||
|
errTestProbeNotExpected = errors.New("probe must not be called")
|
||||||
|
)
|
||||||
|
|
||||||
|
// discardLogger returns a logger that swallows all output, for tests
|
||||||
|
// that exercise code paths which log.
|
||||||
|
func discardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
||||||
|
// explicitly configured cache_max_bytes value is used exactly as
|
||||||
|
// given: the 500 MiB floor applies only to the computed default, never
|
||||||
|
// to explicit values.
|
||||||
|
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 1024 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 1024 (no floor for explicit values)",
|
||||||
|
c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesZeroIsValidAndDisablesCache verifies that an
|
||||||
|
// explicit zero is a valid value (it disables the disk cache), not an
|
||||||
|
// error.
|
||||||
|
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 0 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 0", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
|
||||||
|
// 32-bit range parse correctly (the field is an int64 byte count).
|
||||||
|
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey +
|
||||||
|
"\ncache_max_bytes: 10737418240\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("large cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 10737418240 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 10737418240", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesInvalidValuesAbortStartup verifies that a SET but
|
||||||
|
// invalid cache_max_bytes value aborts startup naming the key and the
|
||||||
|
// offending value, per the no-silent-fallback rule: defaults apply
|
||||||
|
// only to omitted keys.
|
||||||
|
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "negative",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "-1024"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "3.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric string",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: banana\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit null",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: null\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare key no value",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes:\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: true\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "true"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"config with %s cache_max_bytes must abort startup, got config: %+v",
|
||||||
|
tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
|
||||||
|
// computed default is 75% of the probed free space when that exceeds
|
||||||
|
// the floor.
|
||||||
|
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 4 GiB free -> 3 GiB default.
|
||||||
|
probe := func(string) (uint64, error) { return 4294967296, nil }
|
||||||
|
|
||||||
|
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != 3221225472 {
|
||||||
|
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)",
|
||||||
|
got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault
|
||||||
|
// verifies that when 75% of free space is below 500 MiB, the computed
|
||||||
|
// default is floored at DefaultCacheMaxBytesFloor.
|
||||||
|
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
freeBytes uint64
|
||||||
|
}{
|
||||||
|
{name: "100 MiB free", freeBytes: 104857600},
|
||||||
|
{name: "zero free", freeBytes: 0},
|
||||||
|
{name: "just below floor threshold", freeBytes: 699050665},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
|
||||||
|
|
||||||
|
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != DefaultCacheMaxBytesFloor {
|
||||||
|
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want floor %d",
|
||||||
|
got, DefaultCacheMaxBytesFloor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultCacheMaxBytesPropagatesProbeError verifies that a
|
||||||
|
// failing free-space probe produces an error naming the config key,
|
||||||
|
// instead of a silently wrong default.
|
||||||
|
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
probe := func(string) (uint64, error) { return 0, errTestStatfsFailed }
|
||||||
|
|
||||||
|
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("probe failure must produce an error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyCacheMaxBytes) {
|
||||||
|
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveCacheMaxBytesComputesDefaultWhenOmitted verifies that an
|
||||||
|
// omitted cache_max_bytes key resolves to the computed default, that
|
||||||
|
// the probe is pointed at <state_dir>/cache/ (which must be created
|
||||||
|
// first so statfs measures the right filesystem), and that the result
|
||||||
|
// lands on the Config.
|
||||||
|
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.StateDir = t.TempDir()
|
||||||
|
wantCacheDir := filepath.Join(c.StateDir, "cache")
|
||||||
|
|
||||||
|
var probedPath string
|
||||||
|
|
||||||
|
// 4 GiB free -> 3 GiB default.
|
||||||
|
probe := func(path string) (uint64, error) {
|
||||||
|
probedPath = path
|
||||||
|
|
||||||
|
return 4294967296, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 3221225472 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472",
|
||||||
|
c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if probedPath != wantCacheDir {
|
||||||
|
t.Errorf("free space probed at %q, want cache directory %q",
|
||||||
|
probedPath, wantCacheDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(wantCacheDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
||||||
|
wantCacheDir, info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveCacheMaxBytesDoesNotOverrideExplicitValue verifies that
|
||||||
|
// an explicitly configured value survives resolution untouched and
|
||||||
|
// that the free-space probe is never consulted for it.
|
||||||
|
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.StateDir = t.TempDir()
|
||||||
|
|
||||||
|
probe := func(string) (uint64, error) {
|
||||||
|
t.Error("free-space probe must not be consulted for explicit values")
|
||||||
|
|
||||||
|
return 0, errTestProbeNotExpected
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 1024 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
||||||
|
c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheMaxBytesFloor is the minimum computed default for the
|
||||||
|
// cache_max_bytes setting: 500 MiB. The floor applies only to the
|
||||||
|
// computed default (when the key is omitted from the configuration),
|
||||||
|
// never to explicitly configured values.
|
||||||
|
const DefaultCacheMaxBytesFloor int64 = 524288000
|
||||||
|
|
||||||
|
// cacheDirPerms is the permission mode for the cache directory created
|
||||||
|
// before probing free space, matching the state directory permissions.
|
||||||
|
const cacheDirPerms = 0o750
|
||||||
|
|
||||||
|
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
|
||||||
|
// the 75% share of free space used for the computed default limit as
|
||||||
|
// integer arithmetic (dividing before multiplying avoids overflow).
|
||||||
|
const (
|
||||||
|
freeSpaceFractionNumerator uint64 = 3
|
||||||
|
freeSpaceFractionDenominator uint64 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeSpaceProbeFunc reports the number of free bytes available on the
|
||||||
|
// filesystem containing path. It is a function type so tests can
|
||||||
|
// inject a fake probe instead of depending on the host disk.
|
||||||
|
type FreeSpaceProbeFunc func(path string) (uint64, error)
|
||||||
|
|
||||||
|
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
|
||||||
|
// the given path, as available to unprivileged processes.
|
||||||
|
func defaultFreeSpaceProbe(path string) (uint64, error) {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
|
||||||
|
err := syscall.Statfs(path, &stat)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Bsize < 0 {
|
||||||
|
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
|
||||||
|
return stat.Bavail * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
|
||||||
|
// the filesystem containing cacheDir: 75% of the free bytes reported
|
||||||
|
// by probe, with a floor of DefaultCacheMaxBytesFloor.
|
||||||
|
func ComputeDefaultCacheMaxBytes(
|
||||||
|
cacheDir string, probe FreeSpaceProbeFunc,
|
||||||
|
) (int64, error) {
|
||||||
|
freeBytes, err := probe(cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w",
|
||||||
|
"cache_max_bytes", cacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||||
|
computed = min(computed, math.MaxInt64)
|
||||||
|
|
||||||
|
// gosec cannot see that min() above bounds computed, so it reads
|
||||||
|
// this conversion as potentially overflowing. It cannot: computed is
|
||||||
|
// at most math.MaxInt64 on every path here.
|
||||||
|
//nolint:gosec // G115: clamped to MaxInt64 by min above
|
||||||
|
limit := int64(computed)
|
||||||
|
limit = max(limit, DefaultCacheMaxBytesFloor)
|
||||||
|
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCacheMaxBytes finalizes CacheMaxBytes after state_dir
|
||||||
|
// validation: an explicitly configured value is kept as-is (no floor
|
||||||
|
// applies), while an omitted key receives the computed default based
|
||||||
|
// on free space in <state_dir>/cache/. The cache directory is created
|
||||||
|
// first so statfs measures the filesystem that will actually hold the
|
||||||
|
// cache. The effective limit is logged either way.
|
||||||
|
func (c *Config) resolveCacheMaxBytes(
|
||||||
|
log *slog.Logger, probe FreeSpaceProbeFunc,
|
||||||
|
) error {
|
||||||
|
if !c.cacheMaxBytesExplicit {
|
||||||
|
cacheDir := filepath.Join(c.StateDir, "cache")
|
||||||
|
|
||||||
|
err := os.MkdirAll(cacheDir, cacheDirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
|
||||||
|
keyCacheMaxBytes, cacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.CacheMaxBytes = limit
|
||||||
|
|
||||||
|
log.Info("computed default cache size limit from free space",
|
||||||
|
"cache_max_bytes", limit,
|
||||||
|
"cache_dir", cacheDir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("effective cache size limit",
|
||||||
|
"cache_max_bytes", c.CacheMaxBytes,
|
||||||
|
"cache_disabled", c.CacheMaxBytes == 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The variables that set the two concurrency limits.
|
||||||
|
const (
|
||||||
|
testMaxConcurrentProcessingVar = "PIXA_MAX_CONCURRENT_PROCESSING"
|
||||||
|
testUpstreamConnectionsVar = "PIXA_UPSTREAM_CONNECTIONS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
||||||
|
// max_concurrent_processing is the number of CPUs Go uses and an omitted
|
||||||
|
// upstream_connections is 64.
|
||||||
|
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != runtime.GOMAXPROCS(0) {
|
||||||
|
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
|
||||||
|
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnections != 64 {
|
||||||
|
t.Errorf("UpstreamConnections = %d, want 64", c.UpstreamConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
||||||
|
// two limits are used as given.
|
||||||
|
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
"max_concurrent_processing: 3\nupstream_connections: 10\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid config should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != 3 {
|
||||||
|
t.Errorf("MaxConcurrentProcessing = %d, want 3", c.MaxConcurrentProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnections != 10 {
|
||||||
|
t.Errorf("UpstreamConnections = %d, want 10", c.UpstreamConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidConcurrencyLimitAbortsStartup checks that a limit that is
|
||||||
|
// not a whole number of at least 1, or is null, aborts startup naming the
|
||||||
|
// key and the value, and the variable too where the value could have come
|
||||||
|
// from it.
|
||||||
|
func TestInvalidConcurrencyLimitAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
processing := keyMaxConcurrentProcessing
|
||||||
|
connections := keyUpstreamConnections
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing zero",
|
||||||
|
yaml: signingKeyLine + processing + ": 0\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
processing, testMaxConcurrentProcessingVar, "value 0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing negative",
|
||||||
|
yaml: signingKeyLine + processing + ": -2\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
processing, testMaxConcurrentProcessingVar, "value -2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing not a number",
|
||||||
|
yaml: signingKeyLine + processing + ": lots\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
processing, testMaxConcurrentProcessingVar, "lots",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing fractional",
|
||||||
|
yaml: signingKeyLine + processing + ": 1.5\n",
|
||||||
|
wantErrSubstrings: []string{processing, "1.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing null",
|
||||||
|
yaml: signingKeyLine + processing + ": null\n",
|
||||||
|
wantErrSubstrings: []string{processing, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections zero",
|
||||||
|
yaml: signingKeyLine + connections + ": 0\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
connections, testUpstreamConnectionsVar, "value 0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections negative",
|
||||||
|
yaml: signingKeyLine + connections + ": -5\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
connections, testUpstreamConnectionsVar, "value -5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections not a number",
|
||||||
|
yaml: signingKeyLine + connections + ": many\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
connections, testUpstreamConnectionsVar, "many",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections null",
|
||||||
|
yaml: signingKeyLine + connections + ": null\n",
|
||||||
|
wantErrSubstrings: []string{connections, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrencyLimitsFromEnvironment checks that the two variables set
|
||||||
|
// the limits over the config file, and that an invalid value in either
|
||||||
|
// aborts startup naming the variable and the value.
|
||||||
|
func TestConcurrencyLimitsFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv(testMaxConcurrentProcessingVar, "3")
|
||||||
|
t.Setenv(testUpstreamConnectionsVar, "10")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
"max_concurrent_processing: 5\nupstream_connections: 50\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("limits from the environment should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != 3 || c.UpstreamConnections != 10 {
|
||||||
|
t.Errorf("limits = %d and %d, want 3 and 10 from the environment",
|
||||||
|
c.MaxConcurrentProcessing, c.UpstreamConnections)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
variable string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{testMaxConcurrentProcessingVar, "lots"},
|
||||||
|
{testMaxConcurrentProcessingVar, "0"},
|
||||||
|
{testUpstreamConnectionsVar, "-1"},
|
||||||
|
{testUpstreamConnectionsVar, "ten"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.variable+"="+tc.value, func(t *testing.T) {
|
||||||
|
t.Setenv(tc.variable, tc.value)
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, tc.variable, tc.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+1060
-58
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestConfig writes yamlContent to a temp config file and returns
|
||||||
|
// the file path.
|
||||||
|
func writeTestConfig(t *testing.T, yamlContent string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAllowlistHosts loads the config at configPath and asserts that
|
||||||
|
// getStringSlice returns the three expected hosts.
|
||||||
|
func checkAllowlistHosts(t *testing.T, configPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
|
||||||
|
if len(hosts) != 3 {
|
||||||
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
|
||||||
|
for i, want := range expected {
|
||||||
|
if i >= len(hosts) {
|
||||||
|
t.Errorf("missing host at index %d: want %q", i, want)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hosts[i] != want {
|
||||||
|
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := `
|
||||||
|
allowlist_hosts:
|
||||||
|
- static.sneak.cloud
|
||||||
|
- sneak.berlin
|
||||||
|
- s3.sneak.cloud
|
||||||
|
`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Backwards compatibility with comma-separated string values.
|
||||||
|
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := writeTestConfig(t, `port: 8080`)
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
if len(hosts) != 0 {
|
||||||
|
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTestConfig is a helper to load a config file for testing.
|
||||||
|
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||||
|
return smartconfig.NewFromConfigPath(path)
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetStringSlice_YAMLList(t *testing.T) {
|
|
||||||
// Create a temp config file with YAML list format
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `
|
|
||||||
whitelist_hosts:
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- s3.sneak.cloud
|
|
||||||
`
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config using smartconfig
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that getStringSlice correctly parses YAML list
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
|
||||||
for i, want := range expected {
|
|
||||||
if i >= len(hosts) {
|
|
||||||
t.Errorf("missing host at index %d: want %q", i, want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hosts[i] != want {
|
|
||||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|
||||||
// Test backwards compatibility with comma-separated string
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `whitelist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
|
||||||
for i, want := range expected {
|
|
||||||
if i >= len(hosts) {
|
|
||||||
t.Errorf("missing host at index %d: want %q", i, want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hosts[i] != want {
|
|
||||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringSlice_Empty(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `port: 8080`
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if hosts != nil && len(hosts) != 0 {
|
|
||||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadTestConfig is a helper to load a config file for testing
|
|
||||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
|
||||||
return smartconfig.NewFromConfigPath(path)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
|
// minimum length requirement in validate().
|
||||||
|
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
// signingKeyLine is a valid signing_key config line used as the base of
|
||||||
|
// test config files.
|
||||||
|
const signingKeyLine = "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
// testHostS3 is an allowlist host entry used across the config tests.
|
||||||
|
const testHostS3 = "s3.sneak.cloud"
|
||||||
|
|
||||||
|
// nullValueText is the substring that error messages about explicitly
|
||||||
|
// null config values must contain.
|
||||||
|
const nullValueText = "null"
|
||||||
|
|
||||||
|
// abortCase describes a config file that must abort startup with an
|
||||||
|
// error mentioning every string in wantErrSubstrings.
|
||||||
|
type abortCase struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFromYAML writes yamlContent to a temporary config file, loads it
|
||||||
|
// via smartconfig, and constructs a Config from it using the same code
|
||||||
|
// path the server uses at startup.
|
||||||
|
func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc, err := smartconfig.NewFromConfigPath(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFromSmartConfig(sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOmittedValuesUseDefaults(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.Port != DefaultPort {
|
||||||
|
t.Errorf("Port = %d, want default %d", c.Port, DefaultPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != DefaultStateDir {
|
||||||
|
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost != DefaultUpstreamConnectionsPerHost {
|
||||||
|
t.Errorf("UpstreamConnectionsPerHost = %d, want default %d",
|
||||||
|
c.UpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
t.Error("Debug = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaintenanceMode {
|
||||||
|
t.Error("MaintenanceMode = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 0 {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
||||||
|
if c.DBURL != wantDBURL {
|
||||||
|
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := `
|
||||||
|
port: 9090
|
||||||
|
debug: true
|
||||||
|
maintenance_mode: true
|
||||||
|
state_dir: /tmp/pixa-test-state
|
||||||
|
db_url: "file:/tmp/pixa-test-state/other.sqlite3"
|
||||||
|
signing_key: ` + validTestSigningKey + `
|
||||||
|
allowlist_hosts:
|
||||||
|
- s3.sneak.cloud
|
||||||
|
- .example.com
|
||||||
|
allow_http: true
|
||||||
|
upstream_connections_per_host: 5
|
||||||
|
sentry_dsn: "https://abc123@sentry.example.com/42"
|
||||||
|
metrics:
|
||||||
|
username: metricsuser
|
||||||
|
password: metricspass
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid config should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9090 {
|
||||||
|
t.Errorf("Port = %d, want 9090", c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.Debug || !c.MaintenanceMode || !c.AllowHTTP {
|
||||||
|
t.Errorf("bool fields = debug %v maintenance %v allow_http %v, want all true",
|
||||||
|
c.Debug, c.MaintenanceMode, c.AllowHTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != "/tmp/pixa-test-state" {
|
||||||
|
t.Errorf("StateDir = %q, want /tmp/pixa-test-state", c.StateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DBURL != "file:/tmp/pixa-test-state/other.sqlite3" {
|
||||||
|
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
||||||
|
c.AllowlistHosts[1] != ".example.com" {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]",
|
||||||
|
c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost != 5 {
|
||||||
|
t.Errorf("UpstreamConnectionsPerHost = %d, want 5", c.UpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.SentryDSN != "https://abc123@sentry.example.com/42" {
|
||||||
|
t.Errorf("SentryDSN = %q, want explicit value", c.SentryDSN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MetricsUsername != "metricsuser" || c.MetricsPassword != "metricspass" {
|
||||||
|
t.Errorf("metrics = %q/%q, want metricsuser/metricspass",
|
||||||
|
c.MetricsUsername, c.MetricsPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine +
|
||||||
|
`allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
||||||
|
c.AllowlistHosts[1] != "sneak.berlin" {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]",
|
||||||
|
c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAbortCases asserts that each case's config aborts startup with an
|
||||||
|
// error message mentioning every expected substring.
|
||||||
|
func runAbortCases(t *testing.T, cases []abortCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidScalarValueCases are configs where a scalar key is explicitly
|
||||||
|
// set to an unparseable or out-of-range value; each must abort startup
|
||||||
|
// naming the offending key, never silently fall back to the default.
|
||||||
|
func invalidScalarValueCases() []abortCase {
|
||||||
|
return []abortCase{
|
||||||
|
{
|
||||||
|
name: "port not a number",
|
||||||
|
yaml: signingKeyLine + "port: banana\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port zero",
|
||||||
|
yaml: signingKeyLine + "port: 0\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port above 65535",
|
||||||
|
yaml: signingKeyLine + "port: 99999\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "99999"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port fractional",
|
||||||
|
yaml: signingKeyLine + "port: 8080.5\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "8080.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug not a bool",
|
||||||
|
yaml: signingKeyLine + "debug: notabool\n",
|
||||||
|
wantErrSubstrings: []string{keyDebug, "notabool"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode not a bool",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
|
||||||
|
wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http numeric",
|
||||||
|
yaml: signingKeyLine + "allow_http: 2\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowHTTP, "2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host zero",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host negative",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host not a number",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidHostAndCredentialCases are configs where allowlist_hosts,
|
||||||
|
// signing_key, state_dir, sentry_dsn, or metrics is explicitly set to
|
||||||
|
// an invalid value; each must abort startup naming the offending key.
|
||||||
|
func invalidHostAndCredentialCases() []abortCase {
|
||||||
|
return []abortCase{
|
||||||
|
{
|
||||||
|
name: "allowlist host with scheme",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyAllowlistHosts, "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with path",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyAllowlistHosts, "example.com/images",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with whitespace",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist entry not a string",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts, "123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist not a list",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key too short",
|
||||||
|
yaml: "signing_key: short\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key is the documented placeholder",
|
||||||
|
yaml: "signing_key: " + placeholderSigningKey + "\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key missing",
|
||||||
|
yaml: "port: 8080\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir explicitly empty",
|
||||||
|
yaml: signingKeyLine + "state_dir: \"\"\n",
|
||||||
|
wantErrSubstrings: []string{keyStateDir},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn not a URL",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
|
||||||
|
wantErrSubstrings: []string{keySentryDSN, "not a url"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics username without password",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: bob\n",
|
||||||
|
wantErrSubstrings: []string{keyMetrics},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics password without username",
|
||||||
|
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
|
||||||
|
wantErrSubstrings: []string{keyMetrics},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetButInvalidValueAbortsStartup verifies the no-silent-fallback
|
||||||
|
// rule: a key that is explicitly set to an unparseable or out-of-range
|
||||||
|
// value must produce a startup error naming the offending key, never
|
||||||
|
// silently fall back to the default.
|
||||||
|
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, append(
|
||||||
|
invalidScalarValueCases(), invalidHostAndCredentialCases()...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// explicitNullValueCases are configs where a key is explicitly set to
|
||||||
|
// null (including the bare "key:" form and the "~" alias); each must
|
||||||
|
// abort startup naming the key.
|
||||||
|
func explicitNullValueCases() []abortCase {
|
||||||
|
return []abortCase{
|
||||||
|
{
|
||||||
|
name: "port explicit null",
|
||||||
|
yaml: signingKeyLine + "port: null\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port bare key no value",
|
||||||
|
yaml: signingKeyLine + "port:\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug tilde null",
|
||||||
|
yaml: signingKeyLine + "debug: ~\n",
|
||||||
|
wantErrSubstrings: []string{keyDebug, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode null",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: null\n",
|
||||||
|
wantErrSubstrings: []string{keyMaintenanceMode, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http null",
|
||||||
|
yaml: signingKeyLine + "allow_http: null\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowHTTP, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir null",
|
||||||
|
yaml: signingKeyLine + "state_dir: null\n",
|
||||||
|
wantErrSubstrings: []string{keyStateDir, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "db_url null",
|
||||||
|
yaml: signingKeyLine + "db_url: null\n",
|
||||||
|
wantErrSubstrings: []string{keyDBURL, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn null",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: null\n",
|
||||||
|
wantErrSubstrings: []string{keySentryDSN, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host null",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist_hosts null",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key null",
|
||||||
|
yaml: "signing_key: null\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics null",
|
||||||
|
yaml: signingKeyLine + "metrics: null\n",
|
||||||
|
wantErrSubstrings: []string{keyMetrics, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics subkeys null",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyMetricsUsername, keyMetricsPassword, nullValueText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitNullValueAbortsStartup verifies that a key explicitly
|
||||||
|
// set to null (including the bare "key:" form and the "~" alias) aborts
|
||||||
|
// startup naming the key. An explicit null is a SET value: it must
|
||||||
|
// never silently fall back to the default the way an omitted key does.
|
||||||
|
func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, explicitNullValueCases())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
|
||||||
|
// empty string aborts startup: the derived file:...state.sqlite3 URL is
|
||||||
|
// a default, and defaults apply only to omitted keys. This matches
|
||||||
|
// state_dir, where an explicitly empty value already aborts.
|
||||||
|
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + "db_url: \"\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyDBURL) {
|
||||||
|
t.Errorf("error %q does not name the offending key db_url", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no
|
||||||
|
// hostname labels are rejected. The allowlist matcher treats a leading
|
||||||
|
// dot as a suffix pattern, so a bare "." entry would match any upstream
|
||||||
|
// host written in FQDN trailing-dot form (e.g. evil.com.) and
|
||||||
|
// effectively disable URL signing with a single character.
|
||||||
|
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, entry := range []string{".", ".."} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine +
|
||||||
|
"allowlist_hosts:\n - \"" + entry + "\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("allowlist entry %q must abort startup, got config: %+v",
|
||||||
|
entry, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyAllowlistHosts) {
|
||||||
|
t.Errorf("error %q does not name the offending key allowlist_hosts",
|
||||||
|
err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `whitelist_hosts:
|
||||||
|
- example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with unknown key must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "whitelist_hosts") {
|
||||||
|
t.Errorf("error %q does not name the unknown key whitelist_hosts", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `metrics:
|
||||||
|
username: bob
|
||||||
|
password: hunter2
|
||||||
|
port: 9100
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with unknown metrics subkey must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "metrics.port") {
|
||||||
|
t.Errorf("error %q does not name the unknown key metrics.port", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvSectionIsPermitted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `env:
|
||||||
|
PIXA_TEST_ENV_INJECTION: injected
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"env section must be permitted (smartconfig consumes it), got error: %v",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write malformed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFile falls through to the relative config.yml candidate;
|
||||||
|
// the appname is chosen so no /etc or $HOME candidate can exist.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Chdir(tmpDir)
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("malformed config file must abort startup, got config: %v", sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(t.TempDir(), "nested", "state")
|
||||||
|
|
||||||
|
c := &Config{StateDir: stateDir}
|
||||||
|
|
||||||
|
err := c.ensureStateDirWritable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creatable state_dir must validate, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(stateDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("state_dir was not created: info=%v err=%v", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A path below /dev/null can never be created, even when running
|
||||||
|
// as root (as in the Docker build).
|
||||||
|
c := &Config{StateDir: "/dev/null/pixa-state"}
|
||||||
|
|
||||||
|
err := c.ensureStateDirWritable()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("uncreatable state_dir must abort startup, got nil error")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyStateDir) {
|
||||||
|
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()...))
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain unsets PORT and every PIXA_ environment variable before the
|
||||||
|
// tests run, so each test sees only the variables it sets itself, not
|
||||||
|
// whatever the shell running the tests exports.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
if name != "PORT" && !strings.HasPrefix(name, "PIXA_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.Unsetenv(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantStartupError fails the test unless err is a startup error that
|
||||||
|
// mentions every one of wants.
|
||||||
|
func wantStartupError(t *testing.T, err error, wants ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want a startup error mentioning %q, got none", wants)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range wants {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvironmentSetsEveryKey sets every key from its environment
|
||||||
|
// variable, with no config file at all: PORT for the port, and PIXA_
|
||||||
|
// plus the key in upper case, "." written as "_", for every other key.
|
||||||
|
func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_DEBUG", "true")
|
||||||
|
t.Setenv("PIXA_MAINTENANCE_MODE", "1")
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
t.Setenv("PIXA_STATE_DIR", "/srv/pixa-env")
|
||||||
|
t.Setenv("PIXA_SENTRY_DSN", "https://abc123@sentry.example.com/42")
|
||||||
|
t.Setenv("PIXA_DB_URL", "file:/srv/pixa-env/other.sqlite3")
|
||||||
|
t.Setenv("PIXA_METRICS_USERNAME", "metricsuser")
|
||||||
|
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
|
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
||||||
|
t.Setenv("PIXA_ALLOW_HTTP", "true")
|
||||||
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
||||||
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||||
|
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
||||||
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||||
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.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)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("configuration from the environment alone should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := Config{
|
||||||
|
Debug: true,
|
||||||
|
MaintenanceMode: true,
|
||||||
|
Port: 9090,
|
||||||
|
StateDir: "/srv/pixa-env",
|
||||||
|
SentryDSN: "https://abc123@sentry.example.com/42",
|
||||||
|
DBURL: "file:/srv/pixa-env/other.sqlite3",
|
||||||
|
MetricsUsername: "metricsuser",
|
||||||
|
MetricsPassword: "metricspass",
|
||||||
|
SigningKey: validTestSigningKey,
|
||||||
|
AllowlistHosts: []string{testHostS3, ".example.com"},
|
||||||
|
AllowHTTP: true,
|
||||||
|
UpstreamConnectionsPerHost: 5,
|
||||||
|
UpstreamConnections: 10,
|
||||||
|
MaxConcurrentProcessing: 3,
|
||||||
|
CacheMaxBytes: 1024,
|
||||||
|
cacheMaxBytesExplicit: true,
|
||||||
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.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) {
|
||||||
|
t.Errorf("config from the environment =\n%+v\nwant\n%+v", *c, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableAbortsStartup checks that a PIXA_ variable that
|
||||||
|
// is not a setting's variable, such as a misspelled one, aborts startup
|
||||||
|
// naming it, as an unknown config key does, instead of being ignored.
|
||||||
|
func TestUnknownPixaVariableAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
||||||
|
t.Setenv("PIXA_SIGNINGKEY", validTestSigningKey)
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY", "PIXA_SIGNINGKEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPixaPortAbortsStartupPointingToPort checks that PIXA_PORT aborts
|
||||||
|
// startup with a message saying to use PORT, which sets the port.
|
||||||
|
func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_PORT", "9090")
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
wantStartupError(t, err, "PIXA_PORT", "use PORT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
||||||
|
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
||||||
|
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
||||||
|
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||||
|
// A config file's env section loaded by another test can leave a
|
||||||
|
// PIXA_ variable set for the whole process, so every one is unset
|
||||||
|
// here first; t.Setenv restores each when the test ends.
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
if !strings.HasPrefix(name, "PIXA_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(name, "")
|
||||||
|
|
||||||
|
err := os.Unsetenv(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unset %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
||||||
|
|
||||||
|
for _, name := range envVarNames() {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PIXA_CONFIG_PATH and every setting's variable "+
|
||||||
|
"must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFromNew writes yamlContent to a temporary config file, points
|
||||||
|
// PIXA_CONFIG_PATH at it, and runs New, as the server does at startup.
|
||||||
|
// The state directory is a temporary one and the disk cache is off, so
|
||||||
|
// New succeeds unless something in the test is wrong.
|
||||||
|
func configFromNew(t *testing.T, yamlContent string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", configPath)
|
||||||
|
t.Setenv("PIXA_STATE_DIR", filepath.Join(tmpDir, "state"))
|
||||||
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "0")
|
||||||
|
|
||||||
|
testLogger, err := logger.New(nil, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(nil, Params{Logger: testLogger})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableAbortsNew checks that New, which the server
|
||||||
|
// calls at startup, aborts on a misspelled PIXA_ variable.
|
||||||
|
func TestUnknownPixaVariableAbortsNew(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
||||||
|
|
||||||
|
_, err := configFromNew(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableInEnvSectionAbortsNew checks that New aborts
|
||||||
|
// on a misspelled PIXA_ name in the config file's env section, which
|
||||||
|
// loading the file sets as an environment variable.
|
||||||
|
func TestUnknownPixaVariableInEnvSectionAbortsNew(t *testing.T) {
|
||||||
|
// The variable must be absent until the file loads. t.Setenv makes
|
||||||
|
// sure the one the file sets is removed when the test ends.
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "")
|
||||||
|
|
||||||
|
err := os.Unsetenv("PIXA_TRUSTED_PROXY")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unset PIXA_TRUSTED_PROXY: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = configFromNew(t, signingKeyLine+
|
||||||
|
"env:\n PIXA_TRUSTED_PROXY: 192.0.2.0/24\n")
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
|
||||||
|
// the port in the config file.
|
||||||
|
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"port: 8080\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PORT=9090 with port 8080 in the file should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9090 {
|
||||||
|
t.Errorf("Port = %d, want 9090 from PORT, not 8080 from the file", c.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidPortFromEnvironmentAbortsStartup checks that a PORT that is
|
||||||
|
// not a number, or is outside the port range, aborts startup naming PORT
|
||||||
|
// and the value, even though the file's port is valid.
|
||||||
|
func TestInvalidPortFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "banana")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine+"port: 8080\n")
|
||||||
|
wantStartupError(t, err, "PORT", "banana")
|
||||||
|
|
||||||
|
t.Setenv("PORT", "70000")
|
||||||
|
|
||||||
|
_, err = configFromYAML(t, signingKeyLine+"port: 8080\n")
|
||||||
|
wantStartupError(t, err, "PORT", "70000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFromEnvironmentReplacesConfigFileList checks that a list
|
||||||
|
// variable replaces the file's list, split on commas with the spaces
|
||||||
|
// around each entry trimmed.
|
||||||
|
func TestListFromEnvironmentReplacesConfigFileList(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_ALLOWLIST_HOSTS", " cdn.example.com , .example.org ")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
"allowlist_hosts:\n - s3.sneak.cloud\n - sneak.berlin\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PIXA_ALLOWLIST_HOSTS should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"cdn.example.com", ".example.org"}
|
||||||
|
if !slices.Equal(c.AllowlistHosts, want) {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want %v from PIXA_ALLOWLIST_HOSTS",
|
||||||
|
c.AllowlistHosts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidBlockedNetworksFromEnvironmentAbortsStartup checks that an
|
||||||
|
// invalid CIDR, or an empty entry, in PIXA_BLOCKED_NETWORKS aborts
|
||||||
|
// startup naming the variable, as the same list in the file does.
|
||||||
|
func TestInvalidBlockedNetworksFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,not-a-cidr")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS", "not-a-cidr")
|
||||||
|
|
||||||
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,,198.51.100.0/24")
|
||||||
|
|
||||||
|
_, err = configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidDebugFromEnvironmentAbortsStartup checks that a PIXA_DEBUG
|
||||||
|
// that strconv.ParseBool rejects aborts startup instead of defaulting.
|
||||||
|
func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_DEBUG", "maybe")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
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
|
||||||
|
// (TestMain unsets them) the config file's values are used and omitted
|
||||||
|
// keys take their defaults.
|
||||||
|
func TestConfigFileAloneBehavesAsBefore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"port: 9191\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config file should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9191 {
|
||||||
|
t.Errorf("Port = %d, want 9191 from the file", c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != DefaultStateDir {
|
||||||
|
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(c.TrustedProxies, defaultTrustedProxies()) {
|
||||||
|
t.Errorf("TrustedProxies = %v, want default %v",
|
||||||
|
c.TrustedProxies, defaultTrustedProxies())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne checks that an empty
|
||||||
|
// PIXA_TRUSTED_PROXIES is an empty list, like [] in the file: it trusts
|
||||||
|
// no proxy instead of taking the default ranges.
|
||||||
|
func TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXIES", "")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty PIXA_TRUSTED_PROXIES should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.TrustedProxies) != 0 {
|
||||||
|
t.Errorf("TrustedProxies = %v, want none", c.TrustedProxies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmptyVariableDoesNotFallBackToConfigFile checks that a variable
|
||||||
|
// that is present but empty is a set value: an empty PIXA_STATE_DIR
|
||||||
|
// aborts startup like state_dir: "" in the file, instead of falling
|
||||||
|
// through to the file's state_dir.
|
||||||
|
func TestEmptyVariableDoesNotFallBackToConfigFile(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_STATE_DIR", "")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine+"state_dir: /srv/pixa-file\n")
|
||||||
|
wantStartupError(t, err, "PIXA_STATE_DIR")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMissingSigningKeyNamesItsVariable checks that with no config file
|
||||||
|
// and no PIXA_SIGNING_KEY, startup aborts naming the variable, which is
|
||||||
|
// how a container started without it reports the problem.
|
||||||
|
func TestMissingSigningKeyNamesItsVariable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_SIGNING_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretsFromEnvironmentAreNotPrinted checks that errors about the
|
||||||
|
// signing key and the metrics password name their variables but never
|
||||||
|
// print their values.
|
||||||
|
func TestSecretsFromEnvironmentAreNotPrinted(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", "short-signing-secret")
|
||||||
|
|
||||||
|
_, err := newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_SIGNING_KEY")
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), "short-signing-secret") {
|
||||||
|
t.Errorf("error %q prints the signing key", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
|
t.Setenv("PIXA_METRICS_PASSWORD", "metrics-password-secret")
|
||||||
|
|
||||||
|
_, err = newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_METRICS_PASSWORD")
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), "metrics-password-secret") {
|
||||||
|
t.Errorf("error %q prints the metrics password", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: an
|
||||||
|
// explicit CIDR list lands in TrustedProxies in order and replaces the
|
||||||
|
// default, an omitted key falls back to the RFC 1918 private ranges, and an
|
||||||
|
// explicitly empty list trusts no one. The list parser itself is shared with
|
||||||
|
// blocked_networks and is exercised in depth by that key's tests.
|
||||||
|
func TestTrustedProxiesConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("explicit list replaces the default in order", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t,
|
||||||
|
signingKeyLine+`trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]`+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid trusted_proxies should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(c.TrustedProxies))
|
||||||
|
for i, p := range c.TrustedProxies {
|
||||||
|
got[i] = p.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,2001:db8::/32" {
|
||||||
|
t.Errorf("TrustedProxies = %v, want the two ranges in order", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("omitted key defaults to the RFC 1918 ranges", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(c.TrustedProxies))
|
||||||
|
for i, p := range c.TrustedProxies {
|
||||||
|
got[i] = p.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
if joined := strings.Join(got, ","); joined != want {
|
||||||
|
t.Errorf("TrustedProxies = %v, want the RFC 1918 ranges %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicitly empty list trusts no one", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"trusted_proxies: []\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty trusted_proxies should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.TrustedProxies) != 0 {
|
||||||
|
t.Errorf("TrustedProxies = %v, want empty", c.TrustedProxies)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrustedProxiesInvalidAbortsStartup checks that an invalid or null
|
||||||
|
// value aborts startup with an error naming the key and the offending value.
|
||||||
|
func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "invalid cidr",
|
||||||
|
yaml: signingKeyLine + `trusted_proxies: ["999.0.0.0/8"]` + "\n",
|
||||||
|
wantErrSubstrings: []string{keyTrustedProxies, "999.0.0.0/8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null value",
|
||||||
|
yaml: signingKeyLine + "trusted_proxies:\n",
|
||||||
|
wantErrSubstrings: []string{keyTrustedProxies, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -14,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
|
||||||
@@ -29,10 +28,15 @@ const bootstrapVersion = 0
|
|||||||
// Params defines dependencies for Database.
|
// Params defines dependencies for Database.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errInvalidMigrationFilename is returned when a migration filename does
|
||||||
|
// not match the "<version>[_<description>].sql" pattern.
|
||||||
|
var errInvalidMigrationFilename = errors.New("invalid migration filename")
|
||||||
|
|
||||||
// Database wraps the SQL database connection.
|
// Database wraps the SQL database connection.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -48,33 +52,31 @@ type Database struct {
|
|||||||
func ParseMigrationVersion(filename string) (int, error) {
|
func ParseMigrationVersion(filename string) (int, error) {
|
||||||
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return 0, fmt.Errorf("invalid migration filename %q: empty name", filename)
|
return 0, fmt.Errorf("%w %q: empty name", errInvalidMigrationFilename, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split on underscore to separate version from description.
|
// Split on underscore to separate version from description.
|
||||||
// If there's no underscore, the entire stem is the version.
|
// If there's no underscore, the entire stem is the version.
|
||||||
versionStr := name
|
versionStr, _, _ := strings.Cut(name, "_")
|
||||||
if idx := strings.IndexByte(name, '_'); idx >= 0 {
|
|
||||||
versionStr = name[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if versionStr == "" {
|
if versionStr == "" {
|
||||||
return 0, fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
|
return 0, fmt.Errorf(
|
||||||
|
"%w %q: empty version prefix", errInvalidMigrationFilename, filename,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the version is purely numeric.
|
// Validate the version is purely numeric.
|
||||||
for _, ch := range versionStr {
|
for _, ch := range versionStr {
|
||||||
if ch < '0' || ch > '9' {
|
if ch < '0' || ch > '9' {
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"invalid migration filename %q: version %q contains non-numeric character %q",
|
"%w %q: version %q contains non-numeric character %q",
|
||||||
filename, versionStr, string(ch),
|
errInvalidMigrationFilename, filename, versionStr, string(ch),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := strconv.Atoi(versionStr)
|
version, err := strconv.Atoi(versionStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
|
return 0, fmt.Errorf("%w %q: %w", errInvalidMigrationFilename, filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return version, nil
|
return version, nil
|
||||||
@@ -97,6 +99,7 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
s.log.Info("Database OnStop Hook")
|
s.log.Info("Database OnStop Hook")
|
||||||
|
|
||||||
if s.db != nil {
|
if s.db != nil {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
@@ -108,53 +111,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) connect(ctx context.Context) error {
|
// collectMigrations reads the embedded migrations directory and returns
|
||||||
dbURL := s.config.DBURL
|
|
||||||
|
|
||||||
s.log.Info("connecting to database", "url", dbURL)
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", dbURL)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to open database", "error", err)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
|
||||||
s.log.Error("failed to ping database", "error", err)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.db = db
|
|
||||||
s.log.Info("database connected")
|
|
||||||
|
|
||||||
return ApplyMigrations(ctx, s.db, s.log)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectMigrations reads the embedded schema directory and returns
|
|
||||||
// migration filenames sorted lexicographically.
|
// migration filenames sorted lexicographically.
|
||||||
func collectMigrations() ([]string, error) {
|
func collectMigrations() ([]string, error) {
|
||||||
entries, err := 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
|
||||||
|
|
||||||
@@ -169,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 {
|
||||||
@@ -191,16 +170,17 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
|
|||||||
// This is exported so tests can apply the real schema without the full fx
|
// This is exported so tests can apply the real schema without the full fx
|
||||||
// lifecycle.
|
// lifecycle.
|
||||||
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
|
err := bootstrapMigrationsTable(ctx, db, log)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations, err := collectMigrations()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, migration := range migrations {
|
filenames, err := collectMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range filenames {
|
||||||
version, parseErr := ParseMigrationVersion(migration)
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return parseErr
|
return parseErr
|
||||||
@@ -226,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)
|
||||||
}
|
}
|
||||||
@@ -261,3 +241,28 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
func (s *Database) DB() *sql.DB {
|
func (s *Database) DB() *sql.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Database) connect(ctx context.Context) error {
|
||||||
|
dbURL := s.config.DBURL
|
||||||
|
|
||||||
|
s.log.Info("connecting to database", "url", dbURL)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to open database", "error", err)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.PingContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db = db
|
||||||
|
s.log.Info("database connected")
|
||||||
|
|
||||||
|
return ApplyMigrations(ctx, s.db, s.log)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -17,12 +16,14 @@ func openTestDB(t *testing.T) *sql.DB {
|
|||||||
t.Fatalf("failed to open test db: %v", err)
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseMigrationVersion(t *testing.T) {
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
filename string
|
filename string
|
||||||
@@ -78,6 +79,8 @@ func TestParseMigrationVersion(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got, err := ParseMigrationVersion(tt.filename)
|
got, err := ParseMigrationVersion(tt.filename)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -101,37 +104,50 @@ func TestParseMigrationVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
||||||
db := openTestDB(t)
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := ApplyMigrations(ctx, db, nil); err != nil {
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("ApplyMigrations failed: %v", err)
|
t.Fatalf("ApplyMigrations failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The schema_migrations table must exist and contain at least
|
// The schema_migrations table must exist and contain at least
|
||||||
// version 0 (the bootstrap) and 1 (the initial schema).
|
// version 0 (the bootstrap) and 1 (the initial schema).
|
||||||
rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
|
rows, err := db.QueryContext(
|
||||||
|
ctx, "SELECT version FROM schema_migrations ORDER BY version",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query schema_migrations: %v", err)
|
t.Fatalf("failed to query schema_migrations: %v", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var versions []int
|
var versions []int
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v int
|
var v int
|
||||||
if err := rows.Scan(&v); err != nil {
|
|
||||||
t.Fatalf("failed to scan version: %v", err)
|
scanErr := rows.Scan(&v)
|
||||||
|
if scanErr != nil {
|
||||||
|
t.Fatalf("failed to scan version: %v", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions = append(versions, v)
|
versions = append(versions, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("row iteration error: %v", err)
|
t.Fatalf("row iteration error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(versions) < 2 {
|
if len(versions) < 2 {
|
||||||
t.Fatalf("expected at least 2 migrations recorded, got %d: %v", len(versions), versions)
|
t.Fatalf(
|
||||||
|
"expected at least 2 migrations recorded, got %d: %v",
|
||||||
|
len(versions), versions,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if versions[0] != 0 {
|
if versions[0] != 0 {
|
||||||
@@ -143,10 +159,15 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the application tables created by 001.sql exist.
|
// Verify that the application tables created by 001.sql exist.
|
||||||
for _, table := range []string{"source_content", "source_metadata", "output_content", "request_cache", "negative_cache", "cache_stats"} {
|
tables := []string{
|
||||||
|
"source_content", "source_metadata", "output_content",
|
||||||
|
"request_cache", "negative_cache", "cache_stats",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
err := db.QueryRow(
|
err := db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
table,
|
table,
|
||||||
).Scan(&count)
|
).Scan(&count)
|
||||||
@@ -161,22 +182,28 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyMigrations_Idempotent(t *testing.T) {
|
func TestApplyMigrations_Idempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := ApplyMigrations(ctx, db, nil); err != nil {
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("first ApplyMigrations failed: %v", err)
|
t.Fatalf("first ApplyMigrations failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Running a second time must succeed without errors.
|
// Running a second time must succeed without errors.
|
||||||
if err := ApplyMigrations(ctx, db, nil); err != nil {
|
err = ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("second ApplyMigrations failed: %v", err)
|
t.Fatalf("second ApplyMigrations failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify no duplicate rows in schema_migrations.
|
// Verify no duplicate rows in schema_migrations.
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = 0").Scan(&count)
|
err = db.QueryRowContext(
|
||||||
|
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to count version 0 rows: %v", err)
|
t.Fatalf("failed to count version 0 rows: %v", err)
|
||||||
}
|
}
|
||||||
@@ -187,17 +214,21 @@ func TestApplyMigrations_Idempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
||||||
db := openTestDB(t)
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := bootstrapMigrationsTable(ctx, db, nil); err != nil {
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := bootstrapMigrationsTable(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
|
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// schema_migrations table must exist.
|
// schema_migrations table must exist.
|
||||||
var tableCount int
|
var tableCount int
|
||||||
|
|
||||||
err := db.QueryRow(
|
err = db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
).Scan(&tableCount)
|
).Scan(&tableCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,8 +242,8 @@ func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
|||||||
// Version 0 must be recorded.
|
// Version 0 must be recorded.
|
||||||
var recorded int
|
var recorded int
|
||||||
|
|
||||||
err = db.QueryRow(
|
err = db.QueryRowContext(
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
).Scan(&recorded)
|
).Scan(&recorded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to check version: %v", err)
|
t.Fatalf("failed to check version: %v", err)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -3,12 +3,17 @@
|
|||||||
|
|
||||||
-- Source content blobs
|
-- Source content blobs
|
||||||
-- Files stored at: cache/src-content/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/src-content/<ab>/<cd>/<sha256>
|
||||||
|
-- last_accessed_at is NULL until the first LRU touch; eviction falls
|
||||||
|
-- back to fetched_at for rows that have never been touched.
|
||||||
CREATE TABLE IF NOT EXISTS source_content (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
content_hash TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed_at DATETIME
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
|
ON source_content(last_accessed_at);
|
||||||
|
|
||||||
-- Source URL metadata - maps URLs to content hashes
|
-- Source URL metadata - maps URLs to content hashes
|
||||||
-- JSON stored at: cache/src-metadata/<hostname>/<path_hash>.json
|
-- JSON stored at: cache/src-metadata/<hostname>/<path_hash>.json
|
||||||
@@ -34,6 +39,22 @@ CREATE INDEX IF NOT EXISTS idx_source_meta_path_hash ON source_metadata(path_has
|
|||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
||||||
|
|
||||||
|
-- Processed variant blobs
|
||||||
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
||||||
|
-- sidecar with the content type). Tracked here (like source content
|
||||||
|
-- blobs above) so total cache usage can be computed with a SUM query,
|
||||||
|
-- never a directory scan, and so LRU eviction has a timestamp to order
|
||||||
|
-- on.
|
||||||
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
|
ON variant_content(last_accessed_at);
|
||||||
|
|
||||||
-- Output/transformed content blobs
|
-- Output/transformed content blobs
|
||||||
-- Files stored at: cache/dst-content/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/dst-content/<ab>/<cd>/<sha256>
|
||||||
CREATE TABLE IF NOT EXISTS output_content (
|
CREATE TABLE IF NOT EXISTS output_content (
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -48,7 +48,8 @@ type Generator struct {
|
|||||||
key [seal.KeySize]byte
|
key [seal.KeySize]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGenerator creates an encrypted URL generator with a key derived from the signing key.
|
// NewGenerator creates an encrypted URL generator with a key derived
|
||||||
|
// from the signing key.
|
||||||
func NewGenerator(signingKey string) (*Generator, error) {
|
func NewGenerator(signingKey string) (*Generator, error) {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +78,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
// Decrypt
|
// Decrypt
|
||||||
data, err := seal.Decrypt(g.key, token)
|
data, err := seal.Decrypt(g.key, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) {
|
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
||||||
|
errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
return nil, ErrDecryptFailed
|
return nil, ErrDecryptFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +88,9 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
var p Payload
|
var p Payload
|
||||||
if err := cbor.Unmarshal(data, &p); err != nil {
|
|
||||||
|
err = cbor.Unmarshal(data, &p)
|
||||||
|
if err != nil {
|
||||||
return nil, ErrInvalidFormat
|
return nil, ErrInvalidFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,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 == "" {
|
||||||
@@ -116,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,
|
||||||
@@ -127,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
|
Expires: expires,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-50
@@ -1,22 +1,33 @@
|
|||||||
package encurl
|
package encurl_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared test fixture strings.
|
||||||
|
const (
|
||||||
|
testSourceHost = "cdn.example.com"
|
||||||
|
testSourcePath = "/images/photo.jpg"
|
||||||
|
testSourceQuery = "v=2"
|
||||||
|
)
|
||||||
|
|
||||||
func TestGenerator_GenerateAndParse(t *testing.T) {
|
func TestGenerator_GenerateAndParse(t *testing.T) {
|
||||||
gen, err := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
|
gen, err := encurl.NewGenerator("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGenerator() error = %v", err)
|
t.Fatalf("NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &Payload{
|
payload := &encurl.Payload{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: testSourceQuery,
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
@@ -43,38 +54,48 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
if parsed.SourceHost != payload.SourceHost {
|
if parsed.SourceHost != payload.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourcePath != payload.SourcePath {
|
if parsed.SourcePath != payload.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourceQuery != payload.SourceQuery {
|
if parsed.SourceQuery != payload.SourceQuery {
|
||||||
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Width != payload.Width {
|
if parsed.Width != payload.Width {
|
||||||
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Height != payload.Height {
|
if parsed.Height != payload.Height {
|
||||||
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Format != payload.Format {
|
if parsed.Format != payload.Format {
|
||||||
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Quality != payload.Quality {
|
if parsed.Quality != payload.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.FitMode != payload.FitMode {
|
if parsed.FitMode != payload.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.ExpiresAt != payload.ExpiresAt {
|
if parsed.ExpiresAt != payload.ExpiresAt {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_Expired(t *testing.T) {
|
func TestGenerator_Parse_Expired(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +109,15 @@ func TestGenerator_Parse_Expired(t *testing.T) {
|
|||||||
t.Error("Parse() should fail for expired token")
|
t.Error("Parse() should fail for expired token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrExpired {
|
if !errors.Is(err, encurl.ErrExpired) {
|
||||||
t.Errorf("Parse() error = %v, want %v", err, ErrExpired)
|
t.Errorf("Parse() error = %v, want %v", err, encurl.ErrExpired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
|
|
||||||
_, err := gen.Parse("not-a-valid-token")
|
_, err := gen.Parse("not-a-valid-token")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -103,11 +126,13 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +151,14 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
||||||
gen1, _ := NewGenerator("signing-key-1")
|
t.Parallel()
|
||||||
gen2, _ := NewGenerator("signing-key-2")
|
|
||||||
|
|
||||||
payload := &Payload{
|
gen1, _ := encurl.NewGenerator("signing-key-1")
|
||||||
SourceHost: "cdn.example.com",
|
gen2, _ := encurl.NewGenerator("signing-key-2")
|
||||||
SourcePath: "/images/photo.jpg",
|
|
||||||
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +171,12 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest(t *testing.T) {
|
func TestPayload_ToImageRequest(t *testing.T) {
|
||||||
payload := &Payload{
|
t.Parallel()
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
SourceQuery: "v=2",
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
|
SourceQuery: testSourceQuery,
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
@@ -161,55 +190,68 @@ func TestPayload_ToImageRequest(t *testing.T) {
|
|||||||
if req.SourceHost != payload.SourceHost {
|
if req.SourceHost != payload.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != payload.SourcePath {
|
if req.SourcePath != payload.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != payload.SourceQuery {
|
if req.SourceQuery != payload.SourceQuery {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Width != payload.Width {
|
if req.Size.Width != payload.Width {
|
||||||
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Height != payload.Height {
|
if req.Size.Height != payload.Height {
|
||||||
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Format != payload.Format {
|
if req.Format != payload.Format {
|
||||||
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Quality != payload.Quality {
|
if req.Quality != payload.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.FitMode != payload.FitMode {
|
if req.FitMode != payload.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Payload with only required fields - should get defaults
|
// Payload with only required fields - should get defaults
|
||||||
payload := &Payload{
|
payload := &encurl.Payload{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
if req.Format != DefaultFormat {
|
if req.Format != encurl.DefaultFormat {
|
||||||
t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat)
|
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
|
||||||
}
|
}
|
||||||
if req.Quality != DefaultQuality {
|
|
||||||
t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality)
|
if req.Quality != encurl.DefaultQuality {
|
||||||
|
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
if req.FitMode != DefaultFitMode {
|
|
||||||
t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode)
|
if req.FitMode != encurl.DefaultFitMode {
|
||||||
|
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromImageRequest(t *testing.T) {
|
func TestFromImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &imgcache.ImageRequest{
|
req := &imgcache.ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: testSourceQuery,
|
||||||
Size: imgcache.Size{Width: 800, Height: 600},
|
Size: imgcache.Size{Width: 800, Height: 600},
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
Quality: 90,
|
Quality: 90,
|
||||||
@@ -217,52 +259,62 @@ func TestFromImageRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Hour)
|
expiresAt := time.Now().Add(time.Hour)
|
||||||
payload := FromImageRequest(req, expiresAt)
|
payload := encurl.FromImageRequest(req, expiresAt)
|
||||||
|
|
||||||
if payload.SourceHost != req.SourceHost {
|
if payload.SourceHost != req.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.SourcePath != req.SourcePath {
|
if payload.SourcePath != req.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Width != req.Size.Width {
|
if payload.Width != req.Size.Width {
|
||||||
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.ExpiresAt != expiresAt.Unix() {
|
if payload.ExpiresAt != expiresAt.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
||||||
// Request with default values - payload should omit them for smaller encoding
|
t.Parallel()
|
||||||
|
|
||||||
|
// Request with default values - payload should omit them for
|
||||||
|
// smaller encoding
|
||||||
req := &imgcache.ImageRequest{
|
req := &imgcache.ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
Format: DefaultFormat,
|
Format: encurl.DefaultFormat,
|
||||||
Quality: DefaultQuality,
|
Quality: encurl.DefaultQuality,
|
||||||
FitMode: DefaultFitMode,
|
FitMode: encurl.DefaultFitMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := FromImageRequest(req, time.Now().Add(time.Hour))
|
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// These should be zero/empty because they match defaults
|
// These should be zero/empty because they match defaults
|
||||||
if payload.Format != "" {
|
if payload.Format != "" {
|
||||||
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Quality != 0 {
|
if payload.Quality != 0 {
|
||||||
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.FitMode != "" {
|
if payload.FitMode != "" {
|
||||||
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+218
-78
@@ -1,17 +1,37 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errInvalidFormField reports a generator form field, or the q or exp
|
||||||
|
// parameter of /v1/image/, whose value is non-numeric or out of range. The
|
||||||
|
// offending field name is wrapped in so the response can name it.
|
||||||
|
var errInvalidFormField = errors.New("invalid")
|
||||||
|
|
||||||
|
// Bounds for the generator's quality and ttl fields; the quality bounds also
|
||||||
|
// apply to the q parameter of /v1/image/. maxTTL is in seconds: the expiry
|
||||||
|
// calculation time.Duration(ttl) * time.Second overflows above it.
|
||||||
|
const (
|
||||||
|
minQuality = 1
|
||||||
|
maxQuality = 100
|
||||||
|
maxTTL = int(math.MaxInt64 / time.Second)
|
||||||
|
)
|
||||||
|
|
||||||
// HandleRoot serves the login page or generator page based on authentication state.
|
// HandleRoot serves the login page or generator page based on authentication state.
|
||||||
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,20 +43,21 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
|
|
||||||
// Check if authenticated
|
// Check if authenticated
|
||||||
if s.sessMgr.IsAuthenticated(r) {
|
if s.sessMgr.IsAuthenticated(r) {
|
||||||
s.renderGenerator(w, nil)
|
s.renderGenerator(w, r, nil)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login page
|
// Show login page
|
||||||
s.renderLogin(w, "")
|
s.renderLogin(w, r, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLoginPost handles login form submission.
|
// handleLoginPost handles login form submission.
|
||||||
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
s.renderLogin(w, "Invalid form data")
|
if err != nil {
|
||||||
|
s.renderLogin(w, r, "Invalid form data")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -45,21 +66,24 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
||||||
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
s.log.Warn("failed login attempt",
|
||||||
s.renderLogin(w, "Invalid signing key")
|
"remote_addr", clientip.FromContext(r.Context()))
|
||||||
|
s.renderLogin(w, r, "Invalid signing key")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
if err := s.sessMgr.CreateSession(w); err != nil {
|
err = s.sessMgr.CreateSession(w)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to create session", "error", err)
|
s.log.Error("failed to create session", "error", err)
|
||||||
s.renderLogin(w, "Failed to create session")
|
s.renderLogin(w, r, "Failed to create session")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
|
s.log.Info("successful login",
|
||||||
|
"remote_addr", clientip.FromContext(r.Context()))
|
||||||
|
|
||||||
// Redirect to generator page
|
// Redirect to generator page
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -83,85 +107,43 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
if err != nil {
|
||||||
|
s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse form values
|
|
||||||
sourceURL := r.FormValue("url")
|
sourceURL := r.FormValue("url")
|
||||||
widthStr := r.FormValue("width")
|
|
||||||
heightStr := r.FormValue("height")
|
|
||||||
format := r.FormValue("format")
|
|
||||||
qualityStr := r.FormValue("quality")
|
|
||||||
fit := r.FormValue("fit")
|
|
||||||
ttlStr := r.FormValue("ttl")
|
|
||||||
|
|
||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, "Invalid source URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse dimensions
|
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
||||||
width, _ := strconv.Atoi(widthStr)
|
if err != nil {
|
||||||
height, _ := strconv.Atoi(heightStr)
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
quality, _ := strconv.Atoi(qualityStr)
|
http.StatusBadRequest)
|
||||||
ttl, _ := strconv.Atoi(ttlStr)
|
|
||||||
|
|
||||||
if quality <= 0 {
|
return
|
||||||
quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create payload
|
|
||||||
// ttl=0 means never expires
|
|
||||||
var expiresAt time.Time
|
|
||||||
var expiresAtUnix int64
|
|
||||||
|
|
||||||
if ttl > 0 {
|
|
||||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
|
||||||
expiresAtUnix = expiresAt.Unix()
|
|
||||||
}
|
|
||||||
// else expiresAtUnix stays 0 (never expires)
|
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
|
||||||
SourceHost: parsed.Host,
|
|
||||||
SourcePath: parsed.Path,
|
|
||||||
SourceQuery: parsed.RawQuery,
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
Format: imgcache.ImageFormat(format),
|
|
||||||
Quality: quality,
|
|
||||||
FitMode: imgcache.FitMode(fit),
|
|
||||||
ExpiresAt: expiresAtUnix,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build full URL (URL-encode the token for safety)
|
generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format"))
|
||||||
scheme := "https"
|
|
||||||
if s.config.Debug {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine file extension for the trailing filename
|
|
||||||
ext := format
|
|
||||||
if ext == "" || ext == "orig" {
|
|
||||||
ext = "jpg" // Default extension
|
|
||||||
}
|
|
||||||
|
|
||||||
host := r.Host
|
|
||||||
generatedURL := scheme + "://" + host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
|
||||||
|
|
||||||
// Format expiry for display
|
// Format expiry for display
|
||||||
expiresAtStr := "Never"
|
expiresAtStr := "Never"
|
||||||
@@ -169,20 +151,128 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
expiresAtStr = expiresAt.Format(time.RFC3339)
|
expiresAtStr = expiresAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.renderGenerator(w, &generatorData{
|
s.renderGenerator(w, r, &generatorData{
|
||||||
GeneratedURL: generatedURL,
|
GeneratedURL: generatedURL,
|
||||||
ExpiresAt: expiresAtStr,
|
ExpiresAt: expiresAtStr,
|
||||||
FormURL: sourceURL,
|
FormURL: sourceURL,
|
||||||
FormWidth: widthStr,
|
FormWidth: r.FormValue("width"),
|
||||||
FormHeight: heightStr,
|
FormHeight: r.FormValue("height"),
|
||||||
FormFormat: format,
|
FormFormat: r.FormValue("format"),
|
||||||
FormQuality: qualityStr,
|
FormQuality: r.FormValue("quality"),
|
||||||
FormFit: fit,
|
FormFit: r.FormValue("fit"),
|
||||||
FormTTL: ttlStr,
|
FormTTL: r.FormValue("ttl"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
|
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
||||||
|
// non-numeric or out-of-range width, height, quality or ttl, or an
|
||||||
|
// unrecognized fit mode, is a client error naming the offending field. The
|
||||||
|
// format field is passed through unchecked.
|
||||||
|
func buildGeneratePayload(
|
||||||
|
parsed *url.URL, form url.Values,
|
||||||
|
) (*encurl.Payload, time.Time, int, error) {
|
||||||
|
width, err := parseFormDimension(form, "width")
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
height, err := parseFormDimension(form, "height")
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quality, err := parseFormInt(form, "quality",
|
||||||
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl, err := parseFormInt(form, "ttl", 0, 0, maxTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fitMode := imgcache.FitMode(form.Get("fit"))
|
||||||
|
|
||||||
|
err = imgcache.ValidateFitMode(fitMode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0,
|
||||||
|
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
expiresAt time.Time
|
||||||
|
expiresAtUnix int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if ttl > 0 {
|
||||||
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
|
expiresAtUnix = expiresAt.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: parsed.Host,
|
||||||
|
SourcePath: parsed.Path,
|
||||||
|
SourceQuery: parsed.RawQuery,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Format: imgcache.ImageFormat(form.Get("format")),
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: fitMode,
|
||||||
|
ExpiresAt: expiresAtUnix,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, expiresAt, ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
|
// value means "original size" (0). A non-numeric value, or one
|
||||||
|
// imgcache.ValidateDimension rejects, is an error naming the field.
|
||||||
|
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = imgcache.ValidateDimension(field, value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormInt reads an optional integer form field or URL query parameter,
|
||||||
|
// returning def when the field is empty and an error naming the field when the
|
||||||
|
// value is non-numeric or outside minValue to maxValue.
|
||||||
|
func parseFormInt(
|
||||||
|
form url.Values, field string, def, minValue, maxValue int,
|
||||||
|
) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value < minValue || value > maxValue {
|
||||||
|
return 0, fmt.Errorf("%w %s: must be from %d to %d",
|
||||||
|
errInvalidFormField, field, minValue, maxValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
// generatorData holds template data for the generator page.
|
// generatorData holds template data for the generator page.
|
||||||
type generatorData struct {
|
type generatorData struct {
|
||||||
GeneratedURL string
|
GeneratedURL string
|
||||||
@@ -195,38 +285,72 @@ type generatorData struct {
|
|||||||
FormQuality string
|
FormQuality string
|
||||||
FormFit string
|
FormFit string
|
||||||
FormTTL string
|
FormTTL string
|
||||||
|
CSRFField template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
|
func (s *Handlers) renderLogin(
|
||||||
|
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||||
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Error string
|
Error string
|
||||||
|
CSRFField template.HTML
|
||||||
}{
|
}{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
|
CSRFField: csrfField(r),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.Render(w, "login.html", data); err != nil {
|
err := templates.Render(w, "login.html", data)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to render login template", "error", err)
|
s.log.Error("failed to render login template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
|
func (s *Handlers) renderGenerator(
|
||||||
|
w http.ResponseWriter, r *http.Request, data *generatorData,
|
||||||
|
) {
|
||||||
|
s.renderGeneratorStatus(w, r, data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGeneratorStatus renders the generator page with an explicit HTTP
|
||||||
|
// status; a rejected form uses 400. The page is rendered into a buffer before
|
||||||
|
// the status is written, so a template failure can still answer 500.
|
||||||
|
func (s *Handlers) renderGeneratorStatus(
|
||||||
|
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
|
||||||
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = &generatorData{}
|
data = &generatorData{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.Render(w, "generator.html", data); err != nil {
|
data.CSRFField = csrfField(r)
|
||||||
|
|
||||||
|
var page bytes.Buffer
|
||||||
|
|
||||||
|
err := templates.Render(&page, "generator.html", data)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = page.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write generator page", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) {
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
s.renderGenerator(w, &generatorData{
|
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||||
|
form url.Values, status int,
|
||||||
|
) {
|
||||||
|
s.renderGeneratorStatus(w, r, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
@@ -235,5 +359,21 @@ func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg strin
|
|||||||
FormQuality: form.Get("quality"),
|
FormQuality: form.Get("quality"),
|
||||||
FormFit: form.Get("fit"),
|
FormFit: form.Get("fit"),
|
||||||
FormTTL: form.Get("ttl"),
|
FormTTL: form.Get("ttl"),
|
||||||
})
|
}, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||||
|
// Build full URL (URL-encode the token for safety)
|
||||||
|
scheme := "https"
|
||||||
|
if s.config.Debug {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file extension for the trailing filename
|
||||||
|
ext := format
|
||||||
|
if ext == "" || ext == "orig" {
|
||||||
|
ext = "jpg" // Default extension
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFailedLoginLogsResolvedClientIP verifies the failed-login record
|
||||||
|
// carries the resolved client IP from the request context, not the raw
|
||||||
|
// proxy peer address.
|
||||||
|
func TestFailedLoginLogsResolvedClientIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||||
|
config: &config.Config{SigningKey: testSigningKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{loginKeyField: {"wrong-key"}}
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req = req.WithContext(clientip.WithClientIP(req.Context(), "203.0.113.7"))
|
||||||
|
|
||||||
|
h.handleLoginPost(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), `"remote_addr":"203.0.113.7"`) {
|
||||||
|
t.Errorf("failed-login log missing resolved client IP; got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSigningKey is a throwaway signing key for the CSRF flow tests. It
|
||||||
|
// seeds the session manager, the encrypted-URL generator, and the CSRF
|
||||||
|
// token key, exactly as the real signing key does in production.
|
||||||
|
const testSigningKey = "test-signing-key-0123456789abcdef"
|
||||||
|
|
||||||
|
// Form field names used in the CSRF flow tests.
|
||||||
|
const (
|
||||||
|
loginKeyField = "key"
|
||||||
|
// gorilla/csrf's default form field name, not a credential.
|
||||||
|
csrfTokenField = "gorilla.csrf.Token" //nolint:gosec // G101 false positive
|
||||||
|
)
|
||||||
|
|
||||||
|
// csrfFieldPattern extracts the token rendered by csrf.TemplateField into
|
||||||
|
// the form. The field name is gorilla/csrf's default.
|
||||||
|
var csrfFieldPattern = regexp.MustCompile(
|
||||||
|
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
||||||
|
|
||||||
|
// newCSRFTestRouter builds a router that mirrors the production wiring for
|
||||||
|
// the CSRF-protected UI routes (see server.SetupRoutes): the login and
|
||||||
|
// generator forms and their POST targets sit behind the real CSRF
|
||||||
|
// middleware. Requests are marked plaintext (Debug: true) so the flow runs
|
||||||
|
// over httptest's http transport without an https Referer.
|
||||||
|
func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
config: cfg,
|
||||||
|
sessMgr: sessMgr,
|
||||||
|
encGen: encGen,
|
||||||
|
csrfProtect: protect,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(h.CSRF())
|
||||||
|
r.Get("/", h.HandleRoot())
|
||||||
|
r.Post("/", h.HandleRoot())
|
||||||
|
r.Post("/generate", h.HandleGenerateURL())
|
||||||
|
})
|
||||||
|
|
||||||
|
return h, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfCredentials performs a GET that renders a form and returns the CSRF
|
||||||
|
// cookies the middleware set and the token embedded in the form. Passing
|
||||||
|
// the authenticated session cookie renders the generator form instead of
|
||||||
|
// the login form.
|
||||||
|
func csrfCredentials(
|
||||||
|
t *testing.T, srv http.Handler, reqCookies []*http.Cookie,
|
||||||
|
) ([]*http.Cookie, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
for _, c := range reqCookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := csrfFieldPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("no CSRF token field found in rendered form")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec.Result().Cookies(), match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// postForm submits form values with the given cookies and returns the
|
||||||
|
// recorder.
|
||||||
|
func postForm(
|
||||||
|
srv http.Handler, path string,
|
||||||
|
cookies []*http.Cookie, form url.Values,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost, path,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token
|
||||||
|
// is rejected. This is login CSRF: no session cookie exists yet, so the
|
||||||
|
// protection must rest on a token bound to a pre-session cookie.
|
||||||
|
func TestLoginPostRejectedWithoutToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", nil, url.Values{loginKeyField: {testSigningKey}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST / without token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPostRejectedWithForeignToken verifies that a token that does not
|
||||||
|
// match the request's CSRF cookie is rejected: a token minted for one
|
||||||
|
// cookie cannot authorize a request carrying a different cookie.
|
||||||
|
func TestLoginPostRejectedWithForeignToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookiesA, _ := csrfCredentials(t, srv, nil)
|
||||||
|
_, tokenB := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookiesA, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {tokenB},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST / with foreign token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPostAcceptedWithValidToken verifies that POST / with a matching
|
||||||
|
// cookie and token succeeds: the login is processed and a session is
|
||||||
|
// established (303 redirect).
|
||||||
|
func TestLoginPostAcceptedWithValidToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST / with valid token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
var authed bool
|
||||||
|
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName && c.Value != "" {
|
||||||
|
authed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authed {
|
||||||
|
t.Error("valid login did not set a session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectedWithoutToken verifies that POST /generate is
|
||||||
|
// rejected without a CSRF token even when the request carries a valid
|
||||||
|
// authenticated session. The session cookie is not sufficient; the policy
|
||||||
|
// requires a CSRF token on this cookie-authenticated form.
|
||||||
|
func TestGeneratePostRejectedWithoutToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate",
|
||||||
|
[]*http.Cookie{sessionCookie},
|
||||||
|
url.Values{"url": {"https://example.com/a.jpg"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST /generate without token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostAcceptedWithValidToken verifies that POST /generate
|
||||||
|
// succeeds with a valid session and a matching CSRF cookie and token.
|
||||||
|
func TestGeneratePostAcceptedWithValidToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
"url": {"https://example.com/a.jpg"},
|
||||||
|
"format": {"jpeg"},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /generate with valid token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("generator response did not contain a generated URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSessionCookie creates an authenticated session cookie via the
|
||||||
|
// handler's session manager.
|
||||||
|
func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
err := h.sessMgr.CreateSession(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("session manager did not set a %q cookie", session.CookieName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generator form field names, and a value that is not a number.
|
||||||
|
const (
|
||||||
|
widthField = "width"
|
||||||
|
heightField = "height"
|
||||||
|
qualityField = "quality"
|
||||||
|
ttlField = "ttl"
|
||||||
|
fitField = "fit"
|
||||||
|
notANumber = "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generatePost submits the /generate form with a valid session and CSRF token
|
||||||
|
// plus the caller's extra fields, returning the recorder.
|
||||||
|
func generatePost(
|
||||||
|
t *testing.T, extra url.Values,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
}
|
||||||
|
maps.Copy(form, extra)
|
||||||
|
|
||||||
|
return postForm(srv, "/generate", cookies, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
|
||||||
|
// rejected with 400 naming the field rather than being coerced to 0 and
|
||||||
|
// minting a 0-width token.
|
||||||
|
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{"width": {"abc"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a token was generated for non-numeric width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
|
||||||
|
// MaxDimension is rejected at generation time so an unusable token cannot be
|
||||||
|
// minted.
|
||||||
|
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{"width": {"100000"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a token was generated for an over-limit width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsBadField verifies that each generator field whose
|
||||||
|
// value is not a number, out of range, or unrecognized is rejected with 400,
|
||||||
|
// mints no token, and is named in the error shown on the page.
|
||||||
|
func TestGeneratePostRejectsBadField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
field, value, wantError string
|
||||||
|
}{
|
||||||
|
{widthField, notANumber, "invalid width: not a number"},
|
||||||
|
{widthField, "-1", "width is negative"},
|
||||||
|
{widthField, "8193", "width is above 8192"},
|
||||||
|
{heightField, notANumber, "invalid height: not a number"},
|
||||||
|
{heightField, "8193", "height is above 8192"},
|
||||||
|
{qualityField, notANumber, "invalid quality: not a number"},
|
||||||
|
{qualityField, "0", "invalid quality: must be from 1 to 100"},
|
||||||
|
{qualityField, "101", "invalid quality: must be from 1 to 100"},
|
||||||
|
{ttlField, notANumber, "invalid ttl: not a number"},
|
||||||
|
{ttlField, "-1", "invalid ttl: must be from 0 to"},
|
||||||
|
{ttlField, "10000000000", "invalid ttl: must be from 0 to"},
|
||||||
|
{fitField, "bogus", "invalid fit mode: bogus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.field+"="+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{tt.field: {tt.value}})
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(body, "/v1/e/") {
|
||||||
|
t.Error("a token was generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, tt.wantError) {
|
||||||
|
t.Errorf("page does not show %q", tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
||||||
|
// takes the default, and that the largest accepted width, height, quality and
|
||||||
|
// ttl are accepted with an expiry still in the future.
|
||||||
|
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parsed, err := url.Parse(testSourceURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty form: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Quality != encurl.DefaultQuality {
|
||||||
|
t.Errorf("empty quality gave %d, want %d",
|
||||||
|
payload.Quality, encurl.DefaultQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
||||||
|
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
||||||
|
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
||||||
|
qualityField: {strconv.Itoa(maxQuality)},
|
||||||
|
ttlField: {strconv.Itoa(maxTTL)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("largest accepted values: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !expiresAt.After(time.Now()) {
|
||||||
|
t.Errorf("ttl %d gave expiry %v, want a time in the future",
|
||||||
|
maxTTL, expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxFormBytes bounds the request body accepted on the HTML form POST
|
||||||
|
// routes (POST / and POST /generate). The forms carry a handful of short
|
||||||
|
// fields, so 1 MiB is generous while making the bound explicit rather than
|
||||||
|
// resting on ParseForm's incidental 10 MB cap.
|
||||||
|
const MaxFormBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
// LimitBody returns middleware that caps the request body on POST requests
|
||||||
|
// at maxBytes and rejects an oversized body with 413 Request Entity Too
|
||||||
|
// Large.
|
||||||
|
//
|
||||||
|
// It parses the form here, before the CSRF middleware reads the token from
|
||||||
|
// it. The CSRF middleware reads the token with PostFormValue, which
|
||||||
|
// swallows a parse error, so if the body were only capped there an
|
||||||
|
// oversized body would read as a missing token and be refused as 403. By
|
||||||
|
// parsing under the cap first, an oversized body is refused as 413. A
|
||||||
|
// successful parse is cached on the request, so the CSRF check and the
|
||||||
|
// handler reuse it rather than reading the body again.
|
||||||
|
func (s *Handlers) LimitBody(maxBytes int64) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
http.Error(w, "Request body too large",
|
||||||
|
http.StatusRequestEntityTooLarge)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Form field names and a throwaway source image URL for the body-limit
|
||||||
|
// tests.
|
||||||
|
const (
|
||||||
|
sourceURLField = "url"
|
||||||
|
testSourceURL = "https://example.com/a.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newBodyLimitTestRouter mirrors the production wiring for the form POST
|
||||||
|
// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF
|
||||||
|
// middleware, which sits in front of the handlers. maxBytes is the body
|
||||||
|
// cap under test, so a test can trip the limit with a small body.
|
||||||
|
func newBodyLimitTestRouter(
|
||||||
|
t *testing.T, maxBytes int64,
|
||||||
|
) (*Handlers, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
config: cfg,
|
||||||
|
sessMgr: sessMgr,
|
||||||
|
encGen: encGen,
|
||||||
|
csrfProtect: protect,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(h.LimitBody(maxBytes))
|
||||||
|
r.Use(h.CSRF())
|
||||||
|
r.Get("/", h.HandleRoot())
|
||||||
|
r.Post("/", h.HandleRoot())
|
||||||
|
r.Post("/generate", h.HandleGenerateURL())
|
||||||
|
})
|
||||||
|
|
||||||
|
return h, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an
|
||||||
|
// oversized POST / carrying an otherwise valid CSRF cookie and token must
|
||||||
|
// be rejected with 413. If the body limit ran after CSRF, the truncated
|
||||||
|
// body would read as a missing token and return 403; if it ran after the
|
||||||
|
// handler, a valid token would return 303. Getting 413 proves the limit
|
||||||
|
// fires before CSRF parses the form.
|
||||||
|
func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newBodyLimitTestRouter(t, 16)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversized POST / status = %d, want %d",
|
||||||
|
rec.Code, http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for
|
||||||
|
// POST /generate, which also parses a form behind CSRF.
|
||||||
|
func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newBodyLimitTestRouter(t, 16)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversized POST /generate status = %d, want %d",
|
||||||
|
rec.Code, http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a
|
||||||
|
// normal request: under the production cap, a valid login still parses and
|
||||||
|
// establishes a session (303). This guards against the body limit
|
||||||
|
// consuming or corrupting the form the CSRF check and handler depend on.
|
||||||
|
func TestWithinLimitLoginPostSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("within-limit POST / status = %d, want %d",
|
||||||
|
rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
var authed bool
|
||||||
|
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName && c.Value != "" {
|
||||||
|
authed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authed {
|
||||||
|
t.Error("within-limit valid login did not set a session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithinLimitGeneratePostSucceeds is the same non-regression check for
|
||||||
|
// POST /generate.
|
||||||
|
func TestWithinLimitGeneratePostSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
"format": {"jpeg"},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("within-limit POST /generate status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("within-limit generate response did not contain a generated URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// csrfKeySalt provides domain separation for the CSRF authentication key,
|
||||||
|
// derived from the signing key so tokens survive restarts without extra
|
||||||
|
// configuration and never reuse the session or encrypted-URL key material.
|
||||||
|
const csrfKeySalt = "pixa-csrf-v1"
|
||||||
|
|
||||||
|
// newCSRFProtect builds the CSRF-protection middleware for the
|
||||||
|
// state-mutating HTML form routes. The token lives in its own cookie,
|
||||||
|
// independent of the session cookie, so it also protects the login POST
|
||||||
|
// where no session exists yet (login CSRF).
|
||||||
|
//
|
||||||
|
// When plaintext is true (local HTTP development), requests are marked
|
||||||
|
// plaintext so the library neither demands an https Referer nor sets a
|
||||||
|
// Secure cookie the browser would withhold over http. In production the
|
||||||
|
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
||||||
|
// the library enforces its https Referer origin check.
|
||||||
|
func newCSRFProtect(
|
||||||
|
signingKey string, plaintext bool,
|
||||||
|
) (func(http.Handler) http.Handler, error) {
|
||||||
|
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
protect := csrf.Protect(
|
||||||
|
key[:],
|
||||||
|
csrf.Path("/"),
|
||||||
|
csrf.Secure(!plaintext),
|
||||||
|
csrf.SameSite(csrf.SameSiteStrictMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !plaintext {
|
||||||
|
return protect, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
protected := protect(next)
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
||||||
|
})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF returns the CSRF-protection middleware for the login and generator
|
||||||
|
// form routes.
|
||||||
|
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
|
||||||
|
return s.csrfProtect
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfField returns the hidden form input carrying the CSRF token for the
|
||||||
|
// given request, to be embedded verbatim in a rendered form.
|
||||||
|
func csrfField(r *http.Request) template.HTML {
|
||||||
|
return csrf.TemplateField(r)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
// Params defines dependencies for Handlers.
|
// Params defines dependencies for Handlers.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
@@ -37,21 +39,43 @@ type Handlers struct {
|
|||||||
imgCache *imgcache.Cache
|
imgCache *imgcache.Cache
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
|
csrfProtect func(http.Handler) http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
|
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s := &Handlers{
|
s := &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
csrfProtect: csrfProtect,
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
// The eviction goroutine must outlive OnStart, so it cannot
|
||||||
|
// inherit this hook's context. It makes its own instead, which
|
||||||
|
// leaves it uncancellable: an in-flight pass runs to completion
|
||||||
|
// during OnStop regardless of the shutdown deadline. Making the
|
||||||
|
// loop cancellable changes shutdown semantics and is tracked
|
||||||
|
// separately in issue #102, rather than being folded into the
|
||||||
|
// lint-conformance change that surfaced it.
|
||||||
|
//nolint:contextcheck // see issue #102
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return s.initImageService()
|
return s.initImageService()
|
||||||
},
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
if s.imgCache != nil {
|
||||||
|
s.imgCache.StopEviction()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -59,11 +83,15 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
|
|
||||||
// initImageService initializes the image cache and service.
|
// initImageService initializes the image cache and service.
|
||||||
func (s *Handlers) initImageService() error {
|
func (s *Handlers) initImageService() error {
|
||||||
// Create the cache
|
// Create the cache. cache_max_bytes: 0 disables the disk cache
|
||||||
|
// entirely; any other value is the eviction limit in bytes.
|
||||||
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
||||||
StateDir: s.config.StateDir,
|
StateDir: s.config.StateDir,
|
||||||
CacheTTL: imgcache.DefaultCacheTTL,
|
CacheTTL: imgcache.DefaultCacheTTL,
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||||
|
MaxBytes: s.config.CacheMaxBytes,
|
||||||
|
DisableDiskCache: s.config.CacheMaxBytes == 0,
|
||||||
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -71,19 +99,30 @@ func (s *Handlers) initImageService() error {
|
|||||||
|
|
||||||
s.imgCache = cache
|
s.imgCache = cache
|
||||||
|
|
||||||
|
// Background eviction: startup reconciliation, then periodic and
|
||||||
|
// write-pressure passes. No-op when the disk cache is disabled.
|
||||||
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := imgcache.DefaultFetcherConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
|
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
||||||
|
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
||||||
|
|
||||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
||||||
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||||
|
|
||||||
// Create the service
|
// Create the service
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Whitelist: s.config.WhitelistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
|
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
||||||
Logger: s.log,
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,11 +132,13 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgSvc = svc
|
s.imgSvc = svc
|
||||||
s.log.Info("image service initialized")
|
s.log.Info("image service initialized")
|
||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load time)
|
// Initialize session manager (signing key is validated at config load
|
||||||
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||||
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.sessMgr = sessMgr
|
s.sessMgr = sessMgr
|
||||||
|
|
||||||
// Initialize encrypted URL generator
|
// Initialize encrypted URL generator
|
||||||
@@ -105,6 +146,7 @@ func (s *Handlers) initImageService() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.encGen = encGen
|
s.encGen = encGen
|
||||||
|
|
||||||
s.log.Info("session manager and URL generator initialized")
|
s.log.Info("session manager and URL generator initialized")
|
||||||
@@ -112,9 +154,10 @@ func (s *Handlers) initImageService() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status int) {
|
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
err := json.NewEncoder(w).Encode(data)
|
err := json.NewEncoder(w).Encode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,7 +167,7 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status i
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
||||||
s.respondJSON(w, map[string]interface{}{
|
s.respondJSON(w, map[string]any{
|
||||||
"error": message,
|
"error": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
SigningKey: "test-signing-key-must-be-32-chars",
|
SigningKey: "test-signing-key-must-be-32-chars",
|
||||||
Whitelist: []string{goodHost},
|
Allowlist: []string{goodHost},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -82,7 +84,8 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
t.Fatalf("failed to open test db: %v", err)
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +96,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +121,18 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
return &mockFetcher{fs: fs}
|
return &mockFetcher{fs: fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchResult, error) {
|
func (f *mockFetcher) Fetch(
|
||||||
|
_ context.Context, url string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
// Remove https:// prefix
|
// Remove https:// prefix
|
||||||
path := url[8:] // Remove "https://"
|
path := url[8:] // Remove "https://"
|
||||||
|
|
||||||
data, err := fs.ReadFile(f.fs, path)
|
data, err := fs.ReadFile(f.fs, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, imgcache.ErrUpstreamError
|
return nil, httpfetcher.ErrUpstreamError
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.FetchResult{
|
return &httpfetcher.FetchResult{
|
||||||
Content: io.NopCloser(bytes.NewReader(data)),
|
Content: io.NopCloser(bytes.NewReader(data)),
|
||||||
ContentLength: int64(len(data)),
|
ContentLength: int64(len(data)),
|
||||||
ContentType: "image/jpeg",
|
ContentType: "image/jpeg",
|
||||||
@@ -133,13 +140,16 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
// Create a chi router to properly handle wildcards
|
// Create a chi router to properly handle wildcards
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Head("/v1/image/*", fix.handler.HandleImage())
|
r.Head("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodHead, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodHead,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
@@ -166,13 +176,16 @@ func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
// First request to get the ETag
|
// First request to get the ETag
|
||||||
req1 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec1 := httptest.NewRecorder()
|
rec1 := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec1, req1)
|
r.ServeHTTP(rec1, req1)
|
||||||
@@ -187,15 +200,18 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second request with If-None-Match header
|
// Second request with If-None-Match header
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
req2.Header.Set("If-None-Match", etag)
|
req2.Header.Set("If-None-Match", etag)
|
||||||
|
|
||||||
rec2 := httptest.NewRecorder()
|
rec2 := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec2, req2)
|
r.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
// Should return 304 Not Modified
|
// Should return 304 Not Modified
|
||||||
if rec2.Code != http.StatusNotModified {
|
if rec2.Code != http.StatusNotModified {
|
||||||
t.Errorf("Conditional request status = %d, want %d", rec2.Code, http.StatusNotModified)
|
t.Errorf("Conditional request status = %d, want %d",
|
||||||
|
rec2.Code, http.StatusNotModified)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body should be empty for 304 response
|
// Body should be empty for 304 response
|
||||||
@@ -205,21 +221,26 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
// Request with non-matching ETag
|
// Request with non-matching ETag
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
req.Header.Set("If-None-Match", `"different-etag"`)
|
req.Header.Set("If-None-Match", `"different-etag"`)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// Should return 200 OK with full response
|
// Should return 200 OK with full response
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("Request with non-matching ETag status = %d, want %d", rec.Code, http.StatusOK)
|
t.Errorf("Request with non-matching ETag status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body should not be empty
|
// Body should not be empty
|
||||||
@@ -229,12 +250,15 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ETagHeader(t *testing.T) {
|
func TestHandleImage_ETagHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
@@ -253,3 +277,120 @@ func TestHandleImage_ETagHeader(t *testing.T) {
|
|||||||
t.Errorf("ETag should be quoted, got %q", etag)
|
t.Errorf("ETag should be quoted, got %q", etag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image
|
||||||
|
// route rejects an unrecognized fit mode with 400.
|
||||||
|
func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
status := getImage(t, fix,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus")
|
||||||
|
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidQuery_Returns400 verifies that the plain image route
|
||||||
|
// answers a q that is not a whole number from 1 to 100, an empty one
|
||||||
|
// included, with 400 naming q and the value, a parameter given more than once
|
||||||
|
// with 400 naming it, and a query string that cannot be decoded with 400
|
||||||
|
// showing it, instead of serving the image at the default quality 85 or at
|
||||||
|
// the first value given.
|
||||||
|
func TestHandleImage_InvalidQuery_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
query, wantError string
|
||||||
|
}{
|
||||||
|
{"q=banana", `invalid q: not a number, got "banana"`},
|
||||||
|
{"q=0", `invalid q: must be from 1 to 100, got "0"`},
|
||||||
|
{"q=101", `invalid q: must be from 1 to 100, got "101"`},
|
||||||
|
{"q=", `invalid q: not a number, got ""`},
|
||||||
|
{"q=80&q=500", `invalid q: given more than once`},
|
||||||
|
{"q=80&q=", `invalid q: given more than once`},
|
||||||
|
{"fit=cover&fit=contain", `invalid fit: given more than once`},
|
||||||
|
{"q=80%", `invalid query string "q=80%": invalid URL escape "%"`},
|
||||||
|
{
|
||||||
|
"q=50;fit=contain",
|
||||||
|
`invalid query string "q=50;fit=contain": ` +
|
||||||
|
`invalid semicolon separator in query`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.query, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Error != tt.wantError {
|
||||||
|
t.Errorf("error = %q, want %q", body.Error, tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_EmptyFit_Returns400 verifies that the plain image route
|
||||||
|
// answers a fit that is in the URL but empty with 400 naming fit, instead of
|
||||||
|
// serving the image as cover. Only a fit missing from the URL means cover.
|
||||||
|
func TestHandleImage_EmptyFit_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantError := `invalid fit: not a fit mode, got ""`
|
||||||
|
if body.Error != wantError {
|
||||||
|
t.Errorf("error = %q, want %q", body.Error, wantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
+200
-73
@@ -2,12 +2,17 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,64 +20,14 @@ import (
|
|||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
req, ok := s.parseImageRequest(w, r)
|
||||||
|
if !ok {
|
||||||
// Get the wildcard path from chi
|
|
||||||
pathParam := chi.URLParam(r, "*")
|
|
||||||
|
|
||||||
// Parse the URL path
|
|
||||||
parsed, err := imgcache.ParseImagePath(pathParam)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to parse image URL",
|
|
||||||
"path", pathParam,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to ImageRequest
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
// Parse signature params from query string
|
|
||||||
query := r.URL.Query()
|
|
||||||
req.Signature = query.Get("sig")
|
|
||||||
|
|
||||||
if expStr := query.Get("exp"); expStr != "" {
|
|
||||||
if exp, err := strconv.ParseInt(expStr, 10, 64); err == nil {
|
|
||||||
req.Expires = time.Unix(exp, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional quality and fit params
|
|
||||||
if qStr := query.Get("q"); qStr != "" {
|
|
||||||
if q, err := strconv.Atoi(qStr); err == nil && q > 0 && q <= 100 {
|
|
||||||
req.Quality = q
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fit := query.Get("fit"); fit != "" {
|
|
||||||
req.FitMode = imgcache.FitMode(fit)
|
|
||||||
if err := imgcache.ValidateFitMode(req.FitMode); err != nil {
|
|
||||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default quality if not set
|
|
||||||
if req.Quality == 0 {
|
|
||||||
req.Quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fit mode if not set
|
|
||||||
if req.FitMode == "" {
|
|
||||||
req.FitMode = imgcache.FitCover
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate signature if required
|
// Validate signature if required
|
||||||
if err := s.imgSvc.ValidateRequest(req); err != nil {
|
err := s.imgSvc.ValidateRequest(req)
|
||||||
|
if err != nil {
|
||||||
s.log.Warn("signature validation failed",
|
s.log.Warn("signature validation failed",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
@@ -88,8 +43,162 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
|
|
||||||
// Get the image (from cache or fetch/process)
|
// Get the image (from cache or fetch/process)
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
|
||||||
|
resp, err := s.imgSvc.Get(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.respondImageError(w, req, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRobotsTxt serves robots.txt to prevent search engine crawling.
|
||||||
|
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
||||||
|
robotsTxt := []byte("User-agent: *\nDisallow: /\n")
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(robotsTxt)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(robotsTxt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageRequest parses the wildcard path and query parameters into
|
||||||
|
// an ImageRequest. On invalid input it writes an error response and
|
||||||
|
// returns false.
|
||||||
|
func (s *Handlers) parseImageRequest(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) (*imgcache.ImageRequest, bool) {
|
||||||
|
// Get the wildcard path from chi
|
||||||
|
pathParam := chi.URLParam(r, "*")
|
||||||
|
|
||||||
|
// Parse the URL path
|
||||||
|
parsed, err := imgcache.ParseImagePath(pathParam)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to parse image URL",
|
||||||
|
"path", pathParam,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to ImageRequest
|
||||||
|
req := parsed.ToImageRequest()
|
||||||
|
|
||||||
|
// Parse signature params from query string. r.URL.Query() would silently
|
||||||
|
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
||||||
|
// at 85; a query string that cannot be decoded is refused instead. A
|
||||||
|
// parameter given more than once is refused too, as only its first value
|
||||||
|
// would be read.
|
||||||
|
query, err := url.ParseQuery(r.URL.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
||||||
|
r.URL.RawQuery, err), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, values := range query {
|
||||||
|
if len(values) > 1 {
|
||||||
|
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
||||||
|
name), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Signature = query.Get("sig")
|
||||||
|
|
||||||
|
req.Expires, err = parseExpires(query)
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional quality and fit params. Only a q missing from the URL is
|
||||||
|
// 85. A q in the URL that is not a whole number from 1 to 100, an empty
|
||||||
|
// one included, is refused, checked as the generator checks its quality
|
||||||
|
// field; that check alone would take an empty q as missing.
|
||||||
|
qStr := query.Get("q")
|
||||||
|
if query.Has("q") && qStr == "" {
|
||||||
|
s.respondError(w, `invalid q: not a number, got ""`,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Quality, err = parseFormInt(query, "q",
|
||||||
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
||||||
|
// fit mode is refused by the fit-mode check below; that check would take an
|
||||||
|
// empty fit as missing, so an empty one is refused here.
|
||||||
|
req.FitMode = imgcache.FitMode(query.Get("fit"))
|
||||||
|
if query.Has("fit") && req.FitMode == "" {
|
||||||
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fit mode if not set
|
||||||
|
if req.FitMode == "" {
|
||||||
|
req.FitMode = imgcache.FitCover
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||||
|
// route. Dimensions are already bounded by the path parser above; this
|
||||||
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||||
|
// reach the processor as a 500.
|
||||||
|
err = imgcache.ValidateImageRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
||||||
|
// missing from the URL gives the zero time, which the signature check takes
|
||||||
|
// as no expiration. An exp in the URL that is not a whole number, an empty
|
||||||
|
// one included, is an error naming exp and the value.
|
||||||
|
func parseExpires(query url.Values) (time.Time, error) {
|
||||||
|
if !query.Has("exp") {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expStr := query.Get("exp")
|
||||||
|
|
||||||
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("%w exp: not a number, got %q",
|
||||||
|
errInvalidFormField, expStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Unix(exp, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
|
func (s *Handlers) respondImageError(
|
||||||
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
|
) {
|
||||||
s.log.Error("failed to get image",
|
s.log.Error("failed to get image",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
@@ -97,32 +206,63 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Check for specific error types
|
// Check for specific error types
|
||||||
if errors.Is(err, imgcache.ErrSSRFBlocked) {
|
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, imgcache.ErrUpstreamError) {
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
||||||
|
errors.Is(err, imageprocessor.ErrTooManyImages) {
|
||||||
|
s.respondError(w, "server busy, try again later",
|
||||||
|
http.StatusServiceUnavailable)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Content.Close() }()
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
// handling conditional and HEAD requests.
|
||||||
|
func (s *Handlers) writeImageResponse(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
||||||
|
cacheKey imgcache.VariantKey, startTime time.Time,
|
||||||
|
) {
|
||||||
// Set response headers
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 {
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 != "" {
|
||||||
@@ -166,16 +306,3 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
"fetched_bytes", resp.FetchedBytes,
|
"fetched_bytes", resp.FetchedBytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// HandleRobotsTxt serves robots.txt to prevent search engine crawling.
|
|
||||||
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|
||||||
robotsTxt := []byte("User-agent: *\nDisallow: /\n")
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(robotsTxt)))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(robotsTxt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
||||||
|
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
||||||
|
// request that passes the signature check gets 502 from the failed fetch, and
|
||||||
|
// one that fails the check gets 401.
|
||||||
|
const signedHost = "signed.example.com"
|
||||||
|
|
||||||
|
// getImage sends a GET for target to the image route of fix and returns the
|
||||||
|
// response status.
|
||||||
|
func getImage(t *testing.T, fix *testFixtures, target string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET %s: %d", target, rec.Code)
|
||||||
|
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
|
||||||
|
// and fit cover, the values the route uses when a URL has no q or fit, and
|
||||||
|
// sends that signature with each q and fit below.
|
||||||
|
func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
|
sig := signer.Sign(&signature.Request{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: "/images/photo.jpg",
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: string(imgcache.FormatJPEG),
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, sig, expires.Unix())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"no q or fit", "", http.StatusBadGateway},
|
||||||
|
{"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway},
|
||||||
|
{"replayed with q=40", "&q=40", http.StatusUnauthorized},
|
||||||
|
{"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
status := getImage(t, setupTestHandler(t), signedURL+tt.query)
|
||||||
|
if status != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
||||||
|
// service's signed-URL generator to the route.
|
||||||
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
quality int
|
||||||
|
fitMode imgcache.FitMode
|
||||||
|
}{
|
||||||
|
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
||||||
|
{"quality and fit unset", 0, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: "/images/photo.jpg",
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
Quality: tt.quality,
|
||||||
|
FitMode: tt.fitMode,
|
||||||
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getImage(t, fix, signedURL)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is
|
||||||
|
// not a whole number, and one whose exp is empty. Each is refused with 400
|
||||||
|
// naming exp and the value, not with the 401 a URL without exp still gets.
|
||||||
|
func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
query string
|
||||||
|
wantStatus int
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{"sig=x&exp=banana", http.StatusBadRequest,
|
||||||
|
`invalid exp: not a number, got "banana"`},
|
||||||
|
{"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`},
|
||||||
|
{"sig=x", http.StatusUnauthorized, "unauthorized"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.query, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus || body.Error != tt.wantError {
|
||||||
|
t.Errorf("got %d %q, want %d %q",
|
||||||
|
rec.Code, body.Error, tt.wantStatus, tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,14 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted image URLs.
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
||||||
// The trailing path (e.g., /img.jpg) is ignored but helps browsers identify the content type.
|
// image URLs. The trailing path (e.g., /img.jpg) is ignored but helps
|
||||||
|
// browsers identify the content type.
|
||||||
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -48,6 +51,19 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// Convert payload to ImageRequest
|
// Convert payload to ImageRequest
|
||||||
req := payload.ToImageRequest()
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
|
// Apply the same dimension and fit-mode bounds as the plain image
|
||||||
|
// route: a sealed payload is trusted for its origin, not for staying
|
||||||
|
// within limits, so an over-limit size or unknown fit mode is a 400
|
||||||
|
// here rather than an out-of-memory or a 500 from the processor.
|
||||||
|
err = imgcache.ValidateImageRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
s.log.Debug("encrypted image request",
|
s.log.Debug("encrypted image request",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
@@ -56,23 +72,26 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
"format", req.Format,
|
"format", req.Format,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
|
// Fetch and process the image (no signature validation
|
||||||
|
// needed - encrypted URL is trusted)
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
resp, err := s.imgSvc.Get(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.handleImageError(w, err)
|
s.handleImageError(w, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Content.Close() }()
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// Set response headers
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 {
|
||||||
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
|
||||||
@@ -100,12 +119,16 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// handleImageError converts image service errors to HTTP responses.
|
// handleImageError converts image service errors to HTTP responses.
|
||||||
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, imgcache.ErrSSRFBlocked):
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamError):
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamTimeout):
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
||||||
|
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
||||||
|
errors.Is(err, imageprocessor.ErrTooManyImages):
|
||||||
|
s.respondError(w, "server busy, try again later",
|
||||||
|
http.StatusServiceUnavailable)
|
||||||
default:
|
default:
|
||||||
s.log.Error("image request failed", "error", err)
|
s.log.Error("image request failed", "error", err)
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||||
|
// generator seeded by the shared test signing key. The image service is left
|
||||||
|
// nil: these tests exercise validation that rejects a token before any image
|
||||||
|
// is fetched, so the handler must never reach the service.
|
||||||
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
encGen: encGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return encGen, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEncToken issues a GET for the given token and returns the recorder.
|
||||||
|
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
||||||
|
// token requesting a dimension beyond MaxDimension is rejected with 400
|
||||||
|
// instead of reaching the image processor and libvips.
|
||||||
|
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photo.jpg",
|
||||||
|
Width: 100000,
|
||||||
|
Height: 100000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := getEncToken(srv, token)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
||||||
|
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
||||||
|
// as a 500 from the image processor's default branch.
|
||||||
|
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photo.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
FitMode: imgcache.FitMode("bogus"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := getEncToken(srv, token)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServerBusyAnswers503 checks that both image routes answer 503 with
|
||||||
|
// a clear error when the image service gives up waiting for a free upstream
|
||||||
|
// connection or processing slot, wrapped as the service wraps them.
|
||||||
|
func TestServerBusyAnswers503(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
|
||||||
|
|
||||||
|
for _, err := range []error{
|
||||||
|
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
||||||
|
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
||||||
|
} {
|
||||||
|
plain := httptest.NewRecorder()
|
||||||
|
h.respondImageError(plain, req, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for route, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"/v1/image/": plain, "/v1/e/": encrypted,
|
||||||
|
} {
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("%s for %v: status = %d, want %d",
|
||||||
|
route, err, rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
|
t.Errorf("%s for %v: body = %q, want the server busy error",
|
||||||
|
route, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// Params defines dependencies for Healthcheck.
|
// Params defines dependencies for Healthcheck.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
@@ -53,6 +54,8 @@ func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Response is the JSON response for health checks.
|
// Response is the JSON response for health checks.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // health endpoint response format uses snake_case
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Now string `json:"now"`
|
Now string `json:"now"`
|
||||||
@@ -63,10 +66,6 @@ type Response struct {
|
|||||||
Maintenance bool `json:"maintenance_mode"`
|
Maintenance bool `json:"maintenance_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Healthcheck) uptime() time.Duration {
|
|
||||||
return time.Since(s.StartupTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Healthcheck returns the current health status.
|
// Healthcheck returns the current health status.
|
||||||
func (s *Healthcheck) Healthcheck() *Response {
|
func (s *Healthcheck) Healthcheck() *Response {
|
||||||
resp := &Response{
|
resp := &Response{
|
||||||
@@ -81,3 +80,7 @@ func (s *Healthcheck) Healthcheck() *Response {
|
|||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Healthcheck) uptime() time.Duration {
|
||||||
|
return time.Since(s.StartupTime)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
||||||
|
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
|
||||||
|
// forms, alongside public controls that must stay reachable.
|
||||||
|
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"cgnat-low", "100.64.0.1", true},
|
||||||
|
{"cgnat-high", "100.127.255.254", true},
|
||||||
|
{"ietf-protocol", "192.0.0.1", true},
|
||||||
|
{"benchmark-low", "198.18.0.1", true},
|
||||||
|
{"benchmark-high", "198.19.255.254", true},
|
||||||
|
{"nat64", "64:ff9b::1", true},
|
||||||
|
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
|
||||||
|
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
|
||||||
|
{"cloud-metadata", "169.254.169.254", true},
|
||||||
|
{"public-v4", "8.8.8.8", false},
|
||||||
|
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
|
||||||
|
{"public-v6", "2001:4860:4860::8888", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportOf returns the *http.Transport backing a fetcher, so a test can
|
||||||
|
// exercise the SSRF-safe dialer New installed with the operator blocklist.
|
||||||
|
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
transport, ok := f.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
|
||||||
|
// blocked_networks entry is enforced by the dialer, in addition to the
|
||||||
|
// built-in ranges, while an address outside both stays dialable.
|
||||||
|
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
||||||
|
// blocking it can only come from the operator-supplied list.
|
||||||
|
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||||
|
|
||||||
|
transport := transportOf(t, New(cfg))
|
||||||
|
|
||||||
|
blocked := []string{
|
||||||
|
"198.51.100.5:80", // operator-supplied range
|
||||||
|
"10.0.0.5:80", // built-in RFC 1918, still enforced
|
||||||
|
"100.64.0.1:80", // built-in CGNAT range
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range blocked {
|
||||||
|
t.Run("blocked/"+addr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(context.Background(), "tcp", addr)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("public-not-blocked", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A cancelled context makes the dial fail without touching the
|
||||||
|
// network; the point is only that a public literal outside every
|
||||||
|
// blocked range is not SSRF-blocked.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
|
||||||
|
if errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
||||||
|
// public, so validateURL and the redirect check accept it with no DNS lookup,
|
||||||
|
// while the recording dialer routes it to the local httptest server. The
|
||||||
|
// address is reserved for documentation and is never routed on the network.
|
||||||
|
const testPublicHost = "192.0.2.10"
|
||||||
|
|
||||||
|
// imagePayload is the body served by the fake upstream's image route.
|
||||||
|
const imagePayload = "fake-jpeg-bytes"
|
||||||
|
|
||||||
|
// errUnexpectedDial reports a dial to any host other than testPublicHost, which
|
||||||
|
// would mean SSRF protection let a forbidden target reach the transport.
|
||||||
|
var errUnexpectedDial = errors.New("unexpected dial target")
|
||||||
|
|
||||||
|
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
||||||
|
func upstreamURL(path string) string {
|
||||||
|
return "http://" + testPublicHost + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingDialer records every address the transport asks it to dial and
|
||||||
|
// routes connections for testPublicHost to a real local server, so the SSRF
|
||||||
|
// checks run against a public-looking host while bytes go to httptest.
|
||||||
|
type recordingDialer struct {
|
||||||
|
target string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
dialed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *recordingDialer) dialContext(
|
||||||
|
ctx context.Context,
|
||||||
|
network, addr string,
|
||||||
|
) (net.Conn, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.dialed = append(d.dialed, addr)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if host != testPublicHost {
|
||||||
|
return nil, fmt.Errorf("%w: %s", errUnexpectedDial, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
return dialer.DialContext(ctx, network, d.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialedAddrs returns a copy of the addresses the dialer was asked to reach.
|
||||||
|
func (d *recordingDialer) dialedAddrs() []string {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
return slices.Clone(d.dialed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUpstream launches a fake upstream with the routes the fetch tests
|
||||||
|
// exercise and stops it when the test finishes.
|
||||||
|
func startUpstream(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/html", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = io.WriteString(w, "<html></html>")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/private", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/public", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/image", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/chain", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/redirect/hop", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/hop", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/image", http.StatusFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// newServerFetcher builds a fetcher whose transport routes testPublicHost to
|
||||||
|
// srv, leaving the real SSRF validation and redirect checks in place.
|
||||||
|
func newServerFetcher(
|
||||||
|
t *testing.T,
|
||||||
|
srv *httptest.Server,
|
||||||
|
cfg *Config,
|
||||||
|
) (*HTTPFetcher, *recordingDialer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AllowHTTP = true
|
||||||
|
|
||||||
|
f := New(cfg)
|
||||||
|
|
||||||
|
transport, ok := f.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||||
|
transport.DialContext = dialer.dialContext
|
||||||
|
|
||||||
|
return f, dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
// testContext returns a context cancelled when the test ends, bounding any
|
||||||
|
// fetch that would otherwise block on a leaked semaphore slot.
|
||||||
|
func testContext(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchImage fetches path from the fake upstream and fails on error.
|
||||||
|
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchExpectError fetches path and fails unless Fetch returns an error.
|
||||||
|
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||||
|
if err == nil {
|
||||||
|
_ = res.Content.Close()
|
||||||
|
|
||||||
|
t.Fatalf("Fetch(%s) = nil error, want an error", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBody fetches path and returns the fully read, closed response body.
|
||||||
|
func fetchBody(t *testing.T, f *HTTPFetcher, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res := fetchImage(t, f, path)
|
||||||
|
defer func() { _ = res.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(res.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// semLen reports how many per-host semaphore slots are currently held.
|
||||||
|
func semLen(f *HTTPFetcher, host string) int {
|
||||||
|
return len(f.getHostSemaphore(host))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, dialer := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range dialer.dialedAddrs() {
|
||||||
|
if strings.Contains(addr, "169.254.169.254") {
|
||||||
|
t.Errorf("dialer connected to the private redirect target: %s", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectToPublicSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
if body := fetchBody(t, f, "/redirect/public"); body != imagePayload {
|
||||||
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectChainSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
if body := fetchBody(t, f, "/redirect/chain"); body != imagePayload {
|
||||||
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsNon2xx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
err := fetchExpectError(t, f, "/status/500")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsDisallowedContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
err := fetchExpectError(t, f, "/html")
|
||||||
|
if !errors.Is(err, ErrInvalidContentType) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrInvalidContentType", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxResponseSize = 8
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
defer func() { _ = res.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(res.Content)
|
||||||
|
if !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Fatalf("read error = %v, want ErrResponseTooLarge", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > cfg.MaxResponseSize {
|
||||||
|
t.Errorf("read %d bytes, exceeds limit %d", len(data), cfg.MaxResponseSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnectionsPerHost = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
err := fetchExpectError(t, f, "/status/500")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost); held != 0 {
|
||||||
|
t.Fatalf("semaphore slot leaked after error: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One slot per host: this fetch proceeds only if the slot was released.
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
_ = res.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSlotReleasedByClose fetches an image over a one-slot host, hands the
|
||||||
|
// open result to consume, and asserts the slot is held before and freed after,
|
||||||
|
// then that a follow-up fetch can still acquire it.
|
||||||
|
func assertSlotReleasedByClose(
|
||||||
|
t *testing.T,
|
||||||
|
consume func(*testing.T, *FetchResult),
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnectionsPerHost = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
if held := semLen(f, testPublicHost); held != 1 {
|
||||||
|
t.Fatalf("slot not held while body is open: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(t, res)
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost); held != 0 {
|
||||||
|
t.Fatalf("slot not released after close: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
next := fetchImage(t, f, "/image")
|
||||||
|
_ = next.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSemaphoreReleasedOnBodyClose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := io.ReadAll(res.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = res.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close body: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
|
||||||
|
_, err := res.Content.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("partial read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = res.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close body: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
|
||||||
|
// rebinding window: even if validateURL saw a public answer earlier, the
|
||||||
|
// dialer independently re-checks the address it is about to connect to. A full
|
||||||
|
// rebinding simulation (a resolver returning public, then private) would mean
|
||||||
|
// replacing the global net.DefaultResolver with a fake DNS server, which is
|
||||||
|
// heavyweight and unsafe to mutate under parallel -race tests. The property is
|
||||||
|
// proven directly here instead: the dialer rejects a private target outright,
|
||||||
|
// which is exactly the check that fires when a validated host later resolves
|
||||||
|
// to a private address.
|
||||||
|
func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, addr := range []string{
|
||||||
|
"169.254.169.254:80", // link-local (cloud metadata)
|
||||||
|
"127.0.0.1:80", // loopback
|
||||||
|
"10.0.0.5:80", // RFC 1918 private
|
||||||
|
} {
|
||||||
|
t.Run(addr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A cancelled context makes the dial fail immediately without touching the
|
||||||
|
// network; the point is only that a public literal is not SSRF-blocked.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a dial error for an unreachable public target")
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("public target was SSRF-blocked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
||||||
|
// protection, connection limits per host and for all hosts together, and
|
||||||
|
// content-type validation.
|
||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"net/netip"
|
||||||
|
neturl "net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher configuration constants.
|
||||||
|
const (
|
||||||
|
DefaultFetchTimeout = 30 * time.Second
|
||||||
|
DefaultMaxResponseSize = 50 << 20 // 50MB
|
||||||
|
DefaultTLSTimeout = 10 * time.Second
|
||||||
|
DefaultMaxIdleConns = 100
|
||||||
|
DefaultIdleConnTimeout = 90 * time.Second
|
||||||
|
DefaultMaxRedirects = 10
|
||||||
|
DefaultMaxConnectionsPerHost = 20
|
||||||
|
DefaultMaxConnections = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
||||||
|
// MaxConnections fetches are already in progress.
|
||||||
|
const ConnectionWaitTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// MIME content types.
|
||||||
|
const (
|
||||||
|
contentTypeJPEG = "image/jpeg"
|
||||||
|
contentTypePNG = "image/png"
|
||||||
|
contentTypeGIF = "image/gif"
|
||||||
|
contentTypeWebP = "image/webp"
|
||||||
|
contentTypeAVIF = "image/avif"
|
||||||
|
contentTypeSVG = "image/svg+xml"
|
||||||
|
contentTypeOctetStream = "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loopback addresses blocked by SSRF protection.
|
||||||
|
const (
|
||||||
|
localhostIPv4 = "127.0.0.1"
|
||||||
|
localhostIPv6 = "::1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
||||||
|
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
||||||
|
// already cover. They are always blocked, in addition to any
|
||||||
|
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
|
||||||
|
// before matching, so these IPv4 ranges are caught in both forms.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // immutable built-in blocklist
|
||||||
|
var builtinBlockedPrefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
||||||
|
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
||||||
|
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetcher errors.
|
||||||
|
var (
|
||||||
|
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||||
|
ErrInvalidHost = errors.New("invalid or unresolvable host")
|
||||||
|
ErrUnsupportedScheme = errors.New("only HTTPS is supported")
|
||||||
|
ErrResponseTooLarge = errors.New("response exceeds maximum size")
|
||||||
|
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
||||||
|
ErrUpstreamError = errors.New("upstream server error")
|
||||||
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||||
|
ErrTooManyConnections = errors.New("too many concurrent upstream connections")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Internal fetcher errors.
|
||||||
|
var (
|
||||||
|
errTooManyRedirects = errors.New("too many redirects")
|
||||||
|
errConnectFailed = errors.New("failed to connect")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher retrieves content from upstream origins.
|
||||||
|
type Fetcher interface {
|
||||||
|
// Fetch retrieves content from the given URL.
|
||||||
|
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchResult contains the result of fetching from upstream.
|
||||||
|
type FetchResult struct {
|
||||||
|
// Content is the raw image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes (-1 if unknown).
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type from upstream.
|
||||||
|
ContentType string
|
||||||
|
// Headers contains all response headers from upstream.
|
||||||
|
Headers map[string][]string
|
||||||
|
// StatusCode is the HTTP status code from upstream.
|
||||||
|
StatusCode int
|
||||||
|
// FetchDurationMs is how long the fetch took in milliseconds.
|
||||||
|
FetchDurationMs int64
|
||||||
|
// RemoteAddr is the IP:port of the upstream server.
|
||||||
|
RemoteAddr string
|
||||||
|
// HTTPVersion is the protocol version (e.g., "1.1", "2.0").
|
||||||
|
HTTPVersion string
|
||||||
|
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3").
|
||||||
|
TLSVersion string
|
||||||
|
// TLSCipherSuite is the negotiated cipher suite name.
|
||||||
|
TLSCipherSuite string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds configuration for the upstream fetcher.
|
||||||
|
type Config struct {
|
||||||
|
// Timeout for upstream requests.
|
||||||
|
Timeout time.Duration
|
||||||
|
// MaxResponseSize is the maximum allowed response body size.
|
||||||
|
MaxResponseSize int64
|
||||||
|
// UserAgent to send to upstream servers.
|
||||||
|
UserAgent string
|
||||||
|
// AllowedContentTypes is an allow list of MIME types to accept.
|
||||||
|
AllowedContentTypes []string
|
||||||
|
// AllowHTTP allows non-TLS connections (for testing only).
|
||||||
|
AllowHTTP bool
|
||||||
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
|
MaxConnectionsPerHost int
|
||||||
|
// MaxConnections limits concurrent connections to all upstream hosts
|
||||||
|
// together.
|
||||||
|
MaxConnections int
|
||||||
|
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||||
|
// dialer, in addition to the always-enforced built-in ranges.
|
||||||
|
BlockedNetworks []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Timeout: DefaultFetchTimeout,
|
||||||
|
MaxResponseSize: DefaultMaxResponseSize,
|
||||||
|
UserAgent: "pixa/1.0",
|
||||||
|
AllowedContentTypes: []string{
|
||||||
|
contentTypeJPEG,
|
||||||
|
contentTypePNG,
|
||||||
|
contentTypeGIF,
|
||||||
|
contentTypeWebP,
|
||||||
|
contentTypeAVIF,
|
||||||
|
contentTypeSVG,
|
||||||
|
},
|
||||||
|
AllowHTTP: false,
|
||||||
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
|
MaxConnections: DefaultMaxConnections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
||||||
|
// per host and for all hosts together.
|
||||||
|
type HTTPFetcher struct {
|
||||||
|
client *http.Client
|
||||||
|
config *Config
|
||||||
|
hostSems map[string]chan struct{} // per-host semaphores
|
||||||
|
hostSemMu sync.Mutex // protects hostSems map
|
||||||
|
// allHostsSemaphore has one slot per connection allowed to all hosts
|
||||||
|
// together (config.MaxConnections).
|
||||||
|
allHostsSemaphore chan struct{}
|
||||||
|
// connectionWaitTimeout is ConnectionWaitTimeout; tests shorten it.
|
||||||
|
connectionWaitTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
|
func New(config *Config) *HTTPFetcher {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
||||||
|
// re-checks at connect time (closing the DNS-rebinding window) against
|
||||||
|
// both the built-in ranges and the operator-supplied blocklist.
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
||||||
|
},
|
||||||
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||||
|
MaxIdleConns: DefaultMaxIdleConns,
|
||||||
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
// Don't follow redirects automatically - we need to validate each hop
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= DefaultMaxRedirects {
|
||||||
|
return errTooManyRedirects
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the redirect target
|
||||||
|
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("redirect blocked: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTTPFetcher{
|
||||||
|
client: client,
|
||||||
|
config: config,
|
||||||
|
hostSems: make(map[string]chan struct{}),
|
||||||
|
allHostsSemaphore: make(chan struct{}, config.MaxConnections),
|
||||||
|
connectionWaitTimeout: ConnectionWaitTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch retrieves content from the given URL with SSRF protection. When
|
||||||
|
// MaxConnections fetches are already in progress, it waits up to
|
||||||
|
// ConnectionWaitTimeout for one to finish, then fails with
|
||||||
|
// ErrTooManyConnections.
|
||||||
|
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||||
|
// Validate URL before making request
|
||||||
|
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := f.acquireConnection(ctx, extractHost(url))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we fail before returning a result, release the connection
|
||||||
|
success := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if !success {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
parsedURL, err := neturl.Parse(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: parsedURL,
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", f.config.UserAgent)
|
||||||
|
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
||||||
|
|
||||||
|
// Use httptrace to capture connection details
|
||||||
|
var remoteAddr string
|
||||||
|
|
||||||
|
trace := &httptrace.ClientTrace{
|
||||||
|
GotConn: func(info httptrace.GotConnInfo) {
|
||||||
|
if info.Conn != nil {
|
||||||
|
remoteAddr = info.Conn.RemoteAddr().String()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
resp, err := f.client.Do(req)
|
||||||
|
|
||||||
|
fetchDuration := time.Since(startTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, ErrUpstreamTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark success so defer doesn't release the connection; closing the
|
||||||
|
// result's Content does
|
||||||
|
success = true
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireConnection takes a slot for host, then one of the slots shared by
|
||||||
|
// all hosts, and returns the func that gives both back. The host's slot
|
||||||
|
// comes first, so fetches queued for one busy host hold no shared slot.
|
||||||
|
// Only the wait for a shared slot is bounded: after connectionWaitTimeout
|
||||||
|
// it fails with ErrTooManyConnections.
|
||||||
|
func (f *HTTPFetcher) acquireConnection(
|
||||||
|
ctx context.Context, host string,
|
||||||
|
) (func(), error) {
|
||||||
|
hostSem := f.getHostSemaphore(host)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case hostSem <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case f.allHostsSemaphore <- struct{}{}:
|
||||||
|
case <-time.After(f.connectionWaitTimeout):
|
||||||
|
<-hostSem
|
||||||
|
|
||||||
|
return nil, ErrTooManyConnections
|
||||||
|
case <-ctx.Done():
|
||||||
|
<-hostSem
|
||||||
|
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
<-hostSem
|
||||||
|
<-f.allHostsSemaphore
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||||
|
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||||
|
f.hostSemMu.Lock()
|
||||||
|
defer f.hostSemMu.Unlock()
|
||||||
|
|
||||||
|
sem, ok := f.hostSems[host]
|
||||||
|
if !ok {
|
||||||
|
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
||||||
|
f.hostSems[host] = sem
|
||||||
|
}
|
||||||
|
|
||||||
|
return sem
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildResult validates the upstream response and assembles a FetchResult
|
||||||
|
// whose Content calls release when closed.
|
||||||
|
func (f *HTTPFetcher) buildResult(
|
||||||
|
resp *http.Response,
|
||||||
|
remoteAddr string,
|
||||||
|
fetchDuration time.Duration,
|
||||||
|
release func(),
|
||||||
|
) (*FetchResult, error) {
|
||||||
|
// Extract HTTP version (strip "HTTP/" prefix)
|
||||||
|
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
||||||
|
|
||||||
|
// Extract TLS info if available
|
||||||
|
var tlsVersion, tlsCipherSuite string
|
||||||
|
|
||||||
|
if resp.TLS != nil {
|
||||||
|
tlsVersion = tls.VersionName(resp.TLS.Version)
|
||||||
|
tlsCipherSuite = tls.CipherSuiteName(resp.TLS.CipherSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: status %d", ErrUpstreamError, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate content type
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !f.isAllowedContentType(contentType) {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap body with size limiter and semaphore releaser
|
||||||
|
limitedBody := &limitedReader{
|
||||||
|
reader: resp.Body,
|
||||||
|
remaining: f.config.MaxResponseSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FetchResult{
|
||||||
|
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, release},
|
||||||
|
ContentLength: resp.ContentLength,
|
||||||
|
ContentType: contentType,
|
||||||
|
Headers: resp.Header,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
FetchDurationMs: fetchDuration.Milliseconds(),
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
HTTPVersion: httpVersion,
|
||||||
|
TLSVersion: tlsVersion,
|
||||||
|
TLSCipherSuite: tlsCipherSuite,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedContentType checks if the content type is in the allow list.
|
||||||
|
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||||
|
// Extract the MIME type without parameters
|
||||||
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
|
|
||||||
|
for _, allowed := range f.config.AllowedContentTypes {
|
||||||
|
if strings.EqualFold(mediaType, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||||
|
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||||
|
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
||||||
|
return ErrUnsupportedScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse to extract host
|
||||||
|
host := extractHost(rawURL)
|
||||||
|
if host == "" {
|
||||||
|
return ErrInvalidHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove port if present
|
||||||
|
h, _, err := net.SplitHostPort(host)
|
||||||
|
if err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block obvious localhost patterns
|
||||||
|
if isLocalhost(host) {
|
||||||
|
return ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the host to check IP addresses
|
||||||
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
||||||
|
return isPrivateIP(addr.IP)
|
||||||
|
})
|
||||||
|
if private {
|
||||||
|
return ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHost extracts the host from a URL string.
|
||||||
|
func extractHost(rawURL string) string {
|
||||||
|
// Simple extraction without full URL parsing
|
||||||
|
url := rawURL
|
||||||
|
if idx := strings.Index(url, "://"); idx != -1 {
|
||||||
|
url = url[idx+3:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(url, "/"); idx != -1 {
|
||||||
|
url = url[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(url, "?"); idx != -1 {
|
||||||
|
url = url[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalhost checks if the host is localhost.
|
||||||
|
func isLocalhost(host string) bool {
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
|
return host == "localhost" ||
|
||||||
|
host == localhostIPv4 ||
|
||||||
|
host == localhostIPv6 ||
|
||||||
|
host == "[::1]" ||
|
||||||
|
strings.HasSuffix(host, ".localhost") ||
|
||||||
|
strings.HasSuffix(host, ".local")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP checks if an IP is private, loopback, or otherwise internal.
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for loopback
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private ranges
|
||||||
|
if ip.IsPrivate() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for link-local
|
||||||
|
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unspecified (0.0.0.0 or ::)
|
||||||
|
if ip.IsUnspecified() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for multicast
|
||||||
|
if ip.IsMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional checks for IPv4
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
// 169.254.0.0/16 - Link local
|
||||||
|
if ip4[0] == 169 && ip4[1] == 254 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 0.0.0.0/8 - Current network
|
||||||
|
if ip4[0] == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special-use ranges the net.IP predicates above do not cover.
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBlockedIP reports whether ip is refused, either by the built-in
|
||||||
|
// internal-range check or by one of the operator-supplied prefixes.
|
||||||
|
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
||||||
|
if isPrivateIP(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
||||||
|
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
||||||
|
// blocklist; this entry point enforces the built-in ranges alone.
|
||||||
|
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialSSRFSafe(ctx, network, addr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
||||||
|
// internal range or operator-supplied blocked prefix, closing the
|
||||||
|
// DNS-rebinding window at connect time.
|
||||||
|
func dialSSRFSafe(
|
||||||
|
ctx context.Context,
|
||||||
|
network, addr string,
|
||||||
|
blocked []netip.Prefix,
|
||||||
|
) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the address
|
||||||
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all resolved IPs
|
||||||
|
for _, ip := range ips {
|
||||||
|
if isBlockedIP(ip, blocked) {
|
||||||
|
return nil, ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect using the first valid IP
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
addr := net.JoinHostPort(ip.String(), port)
|
||||||
|
|
||||||
|
conn, err := dialer.DialContext(ctx, network, addr)
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitedReader wraps a reader and limits the number of bytes read.
|
||||||
|
type limitedReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
remaining int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *limitedReader) Read(p []byte) (int, error) {
|
||||||
|
if r.remaining <= 0 {
|
||||||
|
return 0, ErrResponseTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(p)) > r.remaining {
|
||||||
|
p = p[:r.remaining]
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
r.remaining -= int64(n)
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// semaphoreReleasingReadCloser releases the fetch's connection slots when
|
||||||
|
// closed.
|
||||||
|
type semaphoreReleasingReadCloser struct {
|
||||||
|
*limitedReader
|
||||||
|
|
||||||
|
closer io.Closer
|
||||||
|
release func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *semaphoreReleasingReadCloser) Close() error {
|
||||||
|
err := r.closer.Close()
|
||||||
|
r.release()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testHost is the hostname used by mock fetch tests.
|
||||||
|
const testHost = "example.com"
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxResponseSize != DefaultMaxResponseSize {
|
||||||
|
t.Errorf("MaxResponseSize = %d, want %d", cfg.MaxResponseSize, DefaultMaxResponseSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxConnectionsPerHost != DefaultMaxConnectionsPerHost {
|
||||||
|
t.Errorf("MaxConnectionsPerHost = %d, want %d",
|
||||||
|
cfg.MaxConnectionsPerHost, DefaultMaxConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP should default to false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedContentTypes) == 0 {
|
||||||
|
t.Error("AllowedContentTypes should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := New(nil)
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("New(nil) returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config == nil {
|
||||||
|
t.Fatal("config should be populated from DefaultConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", f.config.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := New(DefaultConfig())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
contentType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{contentTypeJPEG, true},
|
||||||
|
{contentTypePNG, true},
|
||||||
|
{contentTypeWebP, true},
|
||||||
|
{"image/jpeg; charset=utf-8", true},
|
||||||
|
{"IMAGE/JPEG", true},
|
||||||
|
{"text/html", false},
|
||||||
|
{contentTypeOctetStream, false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.contentType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := f.isAllowedContentType(tc.contentType)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/path", testHost},
|
||||||
|
{"http://example.com:8080/path", "example.com:8080"},
|
||||||
|
{"https://example.com", testHost},
|
||||||
|
{"https://example.com?q=1", testHost},
|
||||||
|
{"example.com/path", testHost},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.url, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := extractHost(tc.url)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLocalhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"localhost", true},
|
||||||
|
{"LOCALHOST", true},
|
||||||
|
{localhostIPv4, true},
|
||||||
|
{localhostIPv6, true},
|
||||||
|
{"[::1]", true},
|
||||||
|
{"foo.localhost", true},
|
||||||
|
{"foo.local", true},
|
||||||
|
{testHost, false},
|
||||||
|
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := isLocalhost(tc.host)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{localhostIPv4, true}, // loopback
|
||||||
|
{"10.0.0.1", true}, // private
|
||||||
|
{"192.168.1.1", true}, // private
|
||||||
|
{"172.16.0.1", true}, // private
|
||||||
|
{"169.254.1.1", true}, // link-local
|
||||||
|
{"0.0.0.0", true}, // unspecified
|
||||||
|
{"224.0.0.1", true}, // multicast
|
||||||
|
{localhostIPv6, true}, // IPv6 loopback
|
||||||
|
{"fe80::1", true}, // IPv6 link-local
|
||||||
|
{"8.8.8.8", false}, // public
|
||||||
|
{"2001:4860:4860::8888", false}, // public IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.ip, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPrivateIP(nil) {
|
||||||
|
t.Error("isPrivateIP(nil) should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "http://example.com/path", false)
|
||||||
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
||||||
|
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
||||||
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||||
|
if errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "https://localhost/path", false)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "https:///path", false)
|
||||||
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.ContentType != contentTypeJPEG {
|
||||||
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "fake-jpeg-data" {
|
||||||
|
t.Errorf("Content = %q, want %q", string(data), "fake-jpeg-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ContentLength != int64(len("fake-jpeg-data")) {
|
||||||
|
t.Errorf("ContentLength = %d, want %d", result.ContentLength, len("fake-jpeg-data"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
|
||||||
|
}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectContentTypeFromPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"foo/bar.jpg", contentTypeJPEG},
|
||||||
|
{"foo/bar.JPG", contentTypeJPEG},
|
||||||
|
{"foo/bar.jpeg", contentTypeJPEG},
|
||||||
|
{"foo/bar.png", contentTypePNG},
|
||||||
|
{"foo/bar.gif", contentTypeGIF},
|
||||||
|
{"foo/bar.webp", contentTypeWebP},
|
||||||
|
{"foo/bar.avif", contentTypeAVIF},
|
||||||
|
{"foo/bar.svg", contentTypeSVG},
|
||||||
|
{"foo/bar.bin", contentTypeOctetStream},
|
||||||
|
{"foo/bar", contentTypeOctetStream},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := detectContentTypeFromPath(tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
src := make([]byte, 100)
|
||||||
|
r := &limitedReader{
|
||||||
|
reader: &byteReader{data: src},
|
||||||
|
remaining: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Read error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 50 {
|
||||||
|
t.Errorf("read %d bytes, should be capped at 50", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain until limit is exhausted.
|
||||||
|
total := n
|
||||||
|
for total < 50 {
|
||||||
|
nn, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("during drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total += nn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the limit is exhausted — next read should error.
|
||||||
|
_, err = r.Read(buf)
|
||||||
|
if !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Errorf("exhausted Read error = %v, want ErrResponseTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteReader is a minimal io.Reader over a byte slice for testing.
|
||||||
|
type byteReader struct {
|
||||||
|
data []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *byteReader) Read(p []byte) (int, error) {
|
||||||
|
if r.pos >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n := copy(p, r.data[r.pos:])
|
||||||
|
r.pos += n
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
||||||
|
// port. Each port is a different host to the per-host limit, while the test
|
||||||
|
// dialer sends every port to the one test server.
|
||||||
|
func imageURLOnPort(port int) string {
|
||||||
|
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
||||||
|
"/image"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfigMaxConnections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := DefaultConfig().MaxConnections; got != DefaultMaxConnections {
|
||||||
|
t.Errorf("MaxConnections = %d, want %d", got, DefaultMaxConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
||||||
|
// counts the fetches to every host together, apart from the per-host
|
||||||
|
// limit: with MaxConnections at 2 and two responses open from two hosts, a
|
||||||
|
// fetch from a third host, which has nothing open, waits the whole wait
|
||||||
|
// timeout and fails with ErrTooManyConnections. Closing one response lets
|
||||||
|
// it through.
|
||||||
|
func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 2
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = second.Content.Close() }()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
|
||||||
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
|
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waited := time.Since(start); waited < f.connectionWaitTimeout {
|
||||||
|
t.Errorf("third Fetch() failed after %v, before waiting %v",
|
||||||
|
waited, f.connectionWaitTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost+":83"); held != 0 {
|
||||||
|
t.Errorf("the refused fetch kept its host's slot: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = first.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close first body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = third.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchFreesHostSlotWhenContextEndsWaitingForConnection checks that a
|
||||||
|
// fetch whose request context ends while it waits for a connection shared
|
||||||
|
// by all hosts gives its host's slot back. With MaxConnections at 1 and one
|
||||||
|
// response open, a fetch from another host takes that host's slot and waits;
|
||||||
|
// its context ends long before the 10 second wait timeout.
|
||||||
|
func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = first.Content.Close() }()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = f.Fetch(ctx, imageURLOnPort(82))
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost+":82"); held != 0 {
|
||||||
|
t.Errorf("the fetch kept its host's slot after its context ended: "+
|
||||||
|
"%d held", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchReleasesConnectionOnError checks that a fetch that fails after
|
||||||
|
// taking its connection gives it back: with MaxConnections at 1, the slot
|
||||||
|
// must be free after the failure and the next fetch must succeed.
|
||||||
|
func TestFetchReleasesConnectionOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
||||||
|
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
|
||||||
|
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
||||||
|
// dialer refuses every host but testPublicHost.
|
||||||
|
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := f.Fetch(testContext(t), tc.url)
|
||||||
|
if !errors.Is(err, tc.want) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(f.allHostsSemaphore); held != 0 {
|
||||||
|
t.Fatalf("connection still held after the error: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
_ = res.Content.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
package imgcache
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockFetcher implements the Fetcher interface using an embedded filesystem.
|
// errEmptyURLPath is returned when a mock URL has no usable path.
|
||||||
|
var errEmptyURLPath = errors.New("empty URL path")
|
||||||
|
|
||||||
|
// MockFetcher implements Fetcher using an embedded filesystem.
|
||||||
// Files are organized as: hostname/path/to/file.ext
|
// Files are organized as: hostname/path/to/file.ext
|
||||||
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
|
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
|
// NewMock creates a new mock fetcher backed by the given filesystem.
|
||||||
func NewMockFetcher(fsys fs.FS) *MockFetcher {
|
func NewMock(fsys fs.FS) *MockFetcher {
|
||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
contentType := detectContentTypeFromPath(path)
|
contentType := detectContentTypeFromPath(path)
|
||||||
|
|
||||||
return &FetchResult{
|
return &FetchResult{
|
||||||
Content: f.(io.ReadCloser),
|
Content: f,
|
||||||
ContentLength: stat.Size(),
|
ContentLength: stat.Size(),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Headers: make(http.Header),
|
Headers: make(http.Header),
|
||||||
@@ -86,7 +88,7 @@ func urlToFSPath(rawURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return "", errors.New("empty URL path")
|
return "", errEmptyURLPath
|
||||||
}
|
}
|
||||||
|
|
||||||
return url, nil
|
return url, nil
|
||||||
@@ -98,18 +100,18 @@ func detectContentTypeFromPath(path string) string {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
||||||
return "image/jpeg"
|
return contentTypeJPEG
|
||||||
case strings.HasSuffix(path, ".png"):
|
case strings.HasSuffix(path, ".png"):
|
||||||
return "image/png"
|
return contentTypePNG
|
||||||
case strings.HasSuffix(path, ".gif"):
|
case strings.HasSuffix(path, ".gif"):
|
||||||
return "image/gif"
|
return contentTypeGIF
|
||||||
case strings.HasSuffix(path, ".webp"):
|
case strings.HasSuffix(path, ".webp"):
|
||||||
return "image/webp"
|
return contentTypeWebP
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return "image/avif"
|
return contentTypeAVIF
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return "image/svg+xml"
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return contentTypeOctetStream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,19 +7,33 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
var vipsOnce sync.Once //nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
||||||
|
var vipsOnce sync.Once
|
||||||
|
|
||||||
// initVips initializes libvips with quiet logging.
|
// initVips initializes libvips with quiet logging, one worker thread per
|
||||||
|
// image and no operation cache. Process already works on one image per CPU
|
||||||
|
// by default, so more threads per image would only compete for the CPUs.
|
||||||
|
// Each request decodes different source bytes, so the operation cache
|
||||||
|
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
||||||
|
// repeated requests are served from pixa's disk cache instead.
|
||||||
func initVips() {
|
func initVips() {
|
||||||
vipsOnce.Do(func() {
|
vipsOnce.Do(func() {
|
||||||
vips.LoggingSettings(nil, vips.LogLevelError)
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
||||||
vips.Startup(nil)
|
vips.Startup(&vips.Config{
|
||||||
|
ConcurrencyLevel: 1,
|
||||||
|
MaxCacheSize: 0,
|
||||||
|
MaxCacheMem: 0,
|
||||||
|
MaxCacheFiles: 0,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,15 +110,31 @@ const DefaultMaxInputBytes = 50 << 20
|
|||||||
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
||||||
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||||
|
|
||||||
// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit.
|
// ErrInputDataTooLarge is returned when the raw input data exceeds the
|
||||||
|
// configured byte limit.
|
||||||
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||||
|
|
||||||
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
||||||
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
|
// processed and none finishes within ProcessingWaitTimeout.
|
||||||
|
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||||
|
|
||||||
|
// ProcessingWaitTimeout is how long Process waits for a free slot when
|
||||||
|
// MaxConcurrentProcessing images are already being processed.
|
||||||
|
const ProcessingWaitTimeout = 10 * time.Second
|
||||||
|
|
||||||
// ImageProcessor implements image transformation using libvips via govips.
|
// ImageProcessor implements image transformation using libvips via govips.
|
||||||
type ImageProcessor struct {
|
type ImageProcessor struct {
|
||||||
maxInputBytes int64
|
maxInputBytes int64
|
||||||
|
// processingSemaphore has one slot per image that may be processed at
|
||||||
|
// once. Process holds a slot from before it reads its input until it
|
||||||
|
// returns, so the input, the decoded image and the output all count.
|
||||||
|
processingSemaphore chan struct{}
|
||||||
|
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
||||||
|
processingWaitTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Params holds configuration for creating an ImageProcessor.
|
// Params holds configuration for creating an ImageProcessor.
|
||||||
@@ -113,6 +143,9 @@ type Params struct {
|
|||||||
// MaxInputBytes is the maximum allowed input size in bytes.
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
// If <= 0, DefaultMaxInputBytes is used.
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
MaxInputBytes int64
|
MaxInputBytes int64
|
||||||
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
|
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
||||||
|
MaxConcurrentProcessing int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new image processor with the given parameters.
|
// New creates a new image processor with the given parameters.
|
||||||
@@ -125,17 +158,34 @@ func New(params Params) *ImageProcessor {
|
|||||||
maxInputBytes = DefaultMaxInputBytes
|
maxInputBytes = DefaultMaxInputBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
||||||
|
if maxConcurrentProcessing <= 0 {
|
||||||
|
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
||||||
|
}
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
maxInputBytes: maxInputBytes,
|
maxInputBytes: maxInputBytes,
|
||||||
|
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
||||||
|
processingWaitTimeout: ProcessingWaitTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request.
|
// Process transforms an image according to the request. When
|
||||||
|
// MaxConcurrentProcessing images are already being processed, it waits up
|
||||||
|
// to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
|
// ErrTooManyImages.
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
_ context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *Request,
|
req *Request,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
|
release, err := p.acquireSlot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer release()
|
||||||
|
|
||||||
// Read input with a size limit to prevent unbounded memory consumption.
|
// Read input with a size limit to prevent unbounded memory consumption.
|
||||||
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
||||||
// the limit without consuming additional memory.
|
// the limit without consuming additional memory.
|
||||||
@@ -157,6 +207,13 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
defer img.Close()
|
defer img.Close()
|
||||||
|
|
||||||
|
// Turn the image upright now: encode strips the EXIF orientation tag,
|
||||||
|
// and sizes below must be worked out on the upright image.
|
||||||
|
err = img.AutoRotate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get original dimensions
|
// Get original dimensions
|
||||||
origWidth := img.Width()
|
origWidth := img.Width()
|
||||||
origHeight := img.Height()
|
origHeight := img.Height()
|
||||||
@@ -170,25 +227,12 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine target dimensions
|
// Determine target dimensions
|
||||||
targetWidth := req.Size.Width
|
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
||||||
targetHeight := req.Size.Height
|
|
||||||
|
|
||||||
// Handle dimension calculation
|
|
||||||
if targetWidth == 0 && targetHeight == 0 {
|
|
||||||
// Both are 0: keep original size
|
|
||||||
targetWidth = origWidth
|
|
||||||
targetHeight = origHeight
|
|
||||||
} else if targetWidth == 0 {
|
|
||||||
// Only height specified: calculate width proportionally
|
|
||||||
targetWidth = origWidth * targetHeight / origHeight
|
|
||||||
} else if targetHeight == 0 {
|
|
||||||
// Only width specified: calculate height proportionally
|
|
||||||
targetHeight = origHeight * targetWidth / origWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize if needed
|
// Resize if needed
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil {
|
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,14 +261,42 @@ func (p *ImageProcessor) Process(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// targetDimensions calculates the output dimensions for a requested size,
|
||||||
|
// scaling proportionally when only one dimension is given and keeping the
|
||||||
|
// original dimensions when both are zero.
|
||||||
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
||||||
|
switch {
|
||||||
|
case size.Width == 0 && size.Height == 0:
|
||||||
|
// Both are 0: keep original size
|
||||||
|
return origWidth, origHeight
|
||||||
|
case size.Width == 0:
|
||||||
|
// Only height specified: calculate width proportionally
|
||||||
|
return origWidth * size.Height / origHeight, size.Height
|
||||||
|
case size.Height == 0:
|
||||||
|
// Only width specified: calculate height proportionally
|
||||||
|
return size.Width, origHeight * size.Width / origWidth
|
||||||
|
default:
|
||||||
|
return size.Width, size.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME types for the supported image formats.
|
||||||
|
const (
|
||||||
|
mimeJPEG = "image/jpeg"
|
||||||
|
mimePNG = "image/png"
|
||||||
|
mimeGIF = "image/gif"
|
||||||
|
mimeWebP = "image/webp"
|
||||||
|
mimeAVIF = "image/avif"
|
||||||
|
)
|
||||||
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read.
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
func (p *ImageProcessor) SupportedInputFormats() []string {
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"image/jpeg",
|
mimeJPEG,
|
||||||
"image/png",
|
mimePNG,
|
||||||
"image/gif",
|
mimeGIF,
|
||||||
"image/webp",
|
mimeWebP,
|
||||||
"image/avif",
|
mimeAVIF,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,20 +315,45 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
|||||||
func FormatToMIME(format Format) string {
|
func FormatToMIME(format Format) string {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case FormatJPEG:
|
||||||
return "image/jpeg"
|
return mimeJPEG
|
||||||
case FormatPNG:
|
case FormatPNG:
|
||||||
return "image/png"
|
return mimePNG
|
||||||
case FormatWebP:
|
case FormatWebP:
|
||||||
return "image/webp"
|
return mimeWebP
|
||||||
case FormatGIF:
|
case FormatGIF:
|
||||||
return "image/gif"
|
return mimeGIF
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return "image/avif"
|
return mimeAVIF
|
||||||
|
case FormatOriginal:
|
||||||
|
return "application/octet-stream"
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
||||||
|
// processingWaitTimeout for one to free up, and returns the func that gives
|
||||||
|
// it back. A free slot is taken even when ctx has ended; only the wait for
|
||||||
|
// one stops when ctx ends, as the rest of Process does not check ctx.
|
||||||
|
func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
||||||
|
release := func() { <-p.processingSemaphore }
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p.processingSemaphore <- struct{}{}:
|
||||||
|
return release, nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p.processingSemaphore <- struct{}{}:
|
||||||
|
return release, nil
|
||||||
|
case <-time.After(p.processingWaitTimeout):
|
||||||
|
return nil, ErrTooManyImages
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// detectFormat returns the format string from a vips image.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
||||||
format := img.Format()
|
format := img.Format()
|
||||||
@@ -270,14 +367,20 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
|||||||
case vips.ImageTypeWEBP:
|
case vips.ImageTypeWEBP:
|
||||||
return "webp"
|
return "webp"
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
return "avif"
|
return string(FormatAVIF)
|
||||||
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
|
vips.ImageTypeJP2K, vips.ImageTypeJXL:
|
||||||
|
return "unknown"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize resizes the image according to the fit mode.
|
// resize resizes the image according to the fit mode.
|
||||||
func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error {
|
func (p *ImageProcessor) resize(
|
||||||
|
img *vips.ImageRef, width, height int, fit FitMode,
|
||||||
|
) error {
|
||||||
switch fit {
|
switch fit {
|
||||||
case FitCover, "":
|
case FitCover, "":
|
||||||
// Resize and crop to fill exact dimensions (default)
|
// Resize and crop to fill exact dimensions (default)
|
||||||
@@ -303,6 +406,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
if img.Width() <= width && img.Height() <= height {
|
if img.Width() <= width && img.Height() <= height {
|
||||||
return nil // Already fits
|
return nil // Already fits
|
||||||
}
|
}
|
||||||
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
imgW, imgH := img.Width(), img.Height()
|
||||||
scaleW := float64(width) / float64(imgW)
|
scaleW := float64(width) / float64(imgW)
|
||||||
scaleH := float64(height) / float64(imgH)
|
scaleH := float64(height) / float64(imgH)
|
||||||
@@ -331,7 +435,9 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
const defaultQuality = 85
|
const defaultQuality = 85
|
||||||
|
|
||||||
// encode encodes an image to the specified format.
|
// encode encodes an image to the specified format.
|
||||||
func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) {
|
func (p *ImageProcessor) encode(
|
||||||
|
img *vips.ImageRef, format Format, quality int,
|
||||||
|
) ([]byte, error) {
|
||||||
if quality <= 0 {
|
if quality <= 0 {
|
||||||
quality = defaultQuality
|
quality = defaultQuality
|
||||||
}
|
}
|
||||||
@@ -367,10 +473,28 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int)
|
|||||||
Quality: quality,
|
Quality: quality,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case FormatOriginal:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported output format: %s", format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stripping drops the ICC profile as well, and clients show an image
|
||||||
|
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
||||||
|
// libvips' built-in profile; govips' own sRGB path variable is set on
|
||||||
|
// first use but read without a lock, so concurrent requests race on it.
|
||||||
|
if img.HasICCProfile() {
|
||||||
|
err := img.TransformICCProfileWithFallback("srgb", "srgb")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
||||||
|
// GIF, which carries none of them.
|
||||||
|
params.StripMetadata = true
|
||||||
|
|
||||||
output, _, err := img.Export(¶ms)
|
output, _, err := img.Export(¶ms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,7 +514,7 @@ func (p *ImageProcessor) formatFromString(format string) Format {
|
|||||||
return FormatGIF
|
return FormatGIF
|
||||||
case "webp":
|
case "webp":
|
||||||
return FormatWebP
|
return FormatWebP
|
||||||
case "avif":
|
case string(FormatAVIF):
|
||||||
return FormatAVIF
|
return FormatAVIF
|
||||||
default:
|
default:
|
||||||
return FormatJPEG
|
return FormatJPEG
|
||||||
|
|||||||
+287
-144
@@ -3,12 +3,15 @@ package imageprocessor
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
@@ -16,7 +19,9 @@ import (
|
|||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
initVips()
|
initVips()
|
||||||
|
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
|
|
||||||
vips.Shutdown()
|
vips.Shutdown()
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
@@ -27,11 +32,11 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
|
|||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
// Fill with a gradient
|
// Fill with a gradient
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, color.RGBA{
|
img.Set(x, y, color.RGBA{
|
||||||
R: uint8(x * 255 / width),
|
R: uint8((x * 255 / width) & 0xff),
|
||||||
G: uint8(y * 255 / height),
|
G: uint8((y * 255 / height) & 0xff),
|
||||||
B: 128,
|
B: 128,
|
||||||
A: 255,
|
A: 255,
|
||||||
})
|
})
|
||||||
@@ -39,7 +44,9 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +58,11 @@ func createTestPNG(t *testing.T, width, height int) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, color.RGBA{
|
img.Set(x, y, color.RGBA{
|
||||||
R: uint8(x * 255 / width),
|
R: uint8((x * 255 / width) & 0xff),
|
||||||
G: uint8(y * 255 / height),
|
G: uint8((y * 255 / height) & 0xff),
|
||||||
B: 128,
|
B: 128,
|
||||||
A: 255,
|
A: 255,
|
||||||
})
|
})
|
||||||
@@ -63,37 +70,54 @@ func createTestPNG(t *testing.T, width, height int) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test PNG: %v", err)
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAVIF reports whether data starts with an AVIF ftyp box.
|
||||||
|
func isAVIF(data []byte) bool {
|
||||||
|
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
brand := string(data[8:12])
|
||||||
|
|
||||||
|
return brand == string(FormatAVIF) || brand == "avis"
|
||||||
|
}
|
||||||
|
|
||||||
// detectMIME is a minimal magic-byte detector for test assertions.
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
func detectMIME(data []byte) string {
|
func detectMIME(data []byte) string {
|
||||||
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
return "image/jpeg"
|
return mimeJPEG
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||||
return "image/png"
|
return mimePNG
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
return "image/gif"
|
return mimeGIF
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
return "image/webp"
|
return mimeWebP
|
||||||
}
|
|
||||||
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
|
|
||||||
brand := string(data[8:12])
|
|
||||||
if brand == "avif" || brand == "avis" {
|
|
||||||
return "image/avif"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isAVIF(data) {
|
||||||
|
return mimeAVIF
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -110,7 +134,8 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
if result.Width != 400 {
|
if result.Width != 400 {
|
||||||
t.Errorf("Process() width = %d, want 400", result.Width)
|
t.Errorf("Process() width = %d, want 400", result.Width)
|
||||||
@@ -131,12 +156,14 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != "image/jpeg" {
|
if mime != mimeJPEG {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -152,7 +179,8 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,19 +188,25 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != "image/png" {
|
if mime != mimePNG {
|
||||||
t.Errorf("Output format = %v, want image/png", mime)
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_OriginalSize(t *testing.T) {
|
// processAndCheckSize processes a test JPEG of the given input dimensions
|
||||||
|
// with the requested size and asserts the resulting dimensions.
|
||||||
|
func processAndCheckSize(
|
||||||
|
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 640, 480)
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
|
|
||||||
req := &Request{
|
req := &Request{
|
||||||
Size: Size{Width: 0, Height: 0}, // Original size
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
@@ -182,18 +216,28 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
if result.Width != 640 {
|
defer func() { _ = result.Content.Close() }()
|
||||||
t.Errorf("Process() width = %d, want 640", result.Width)
|
|
||||||
|
if result.Width != wantW {
|
||||||
|
t.Errorf("Process() width = %d, want %d", result.Width, wantW)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Height != 480 {
|
if result.Height != wantH {
|
||||||
t.Errorf("Process() height = %d, want 480", result.Height)
|
t.Errorf("Process() height = %d, want %d", result.Height, wantH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Width and height 0: keep original size
|
||||||
|
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -212,7 +256,8 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
// With contain, the image should fit within the box
|
// With contain, the image should fit within the box
|
||||||
if result.Width > 400 || result.Height > 400 {
|
if result.Width > 400 || result.Height > 400 {
|
||||||
@@ -221,66 +266,24 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
||||||
proc := New(Params{})
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 800x600 image, request width=400 height=0
|
// 800x600 image, request width=400 height=0
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
input := createTestJPEG(t, 800, 600)
|
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
|
||||||
|
|
||||||
req := &Request{
|
|
||||||
Size: Size{Width: 400, Height: 0},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process() error = %v", err)
|
|
||||||
}
|
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
if result.Width != 400 {
|
|
||||||
t.Errorf("Process() width = %d, want 400", result.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Height != 300 {
|
|
||||||
t.Errorf("Process() height = %d, want 300", result.Height)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
proc := New(Params{})
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 800x600 image, request width=0 height=300
|
// 800x600 image, request width=0 height=300
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
input := createTestJPEG(t, 800, 600)
|
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
|
||||||
|
|
||||||
req := &Request{
|
|
||||||
Size: Size{Width: 0, Height: 300},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process() error = %v", err)
|
|
||||||
}
|
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
if result.Width != 400 {
|
|
||||||
t.Errorf("Process() width = %d, want 400", result.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Height != 300 {
|
|
||||||
t.Errorf("Process() height = %d, want 300", result.Height)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -296,7 +299,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
if result.Width != 200 {
|
if result.Width != 200 {
|
||||||
t.Errorf("Process() width = %d, want 200", result.Width)
|
t.Errorf("Process() width = %d, want 200", result.Width)
|
||||||
@@ -308,6 +312,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
|
|
||||||
inputFormats := proc.SupportedInputFormats()
|
inputFormats := proc.SupportedInputFormats()
|
||||||
@@ -322,12 +328,26 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Images exceeding MaxInputDimension in either dimension must be
|
||||||
|
// rejected before processing to prevent DoS.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}{
|
||||||
|
{name: "oversized width", width: 10000, height: 100},
|
||||||
|
{name: "oversized height", width: 100, height: 10000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
|
|
||||||
// This should be rejected before processing to prevent DoS
|
|
||||||
input := createTestJPEG(t, 10000, 100)
|
|
||||||
|
|
||||||
req := &Request{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: Size{Width: 100, Height: 100},
|
||||||
@@ -341,36 +361,16 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
t.Error("Process() should reject oversized input images")
|
t.Error("Process() should reject oversized input images")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInputTooLarge {
|
if !errors.Is(err, ErrInputTooLarge) {
|
||||||
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
|
||||||
proc := New(Params{})
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create an image with oversized height
|
|
||||||
input := createTestJPEG(t, 100, 10000)
|
|
||||||
|
|
||||||
req := &Request{
|
|
||||||
Size: Size{Width: 100, Height: 100},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Process() should reject oversized input images")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != ErrInputTooLarge {
|
|
||||||
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -386,12 +386,20 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() should accept images at MaxInputDimension, got error: %v", err)
|
t.Fatalf(
|
||||||
}
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
defer result.Content.Close()
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
||||||
|
// given format and asserts the output MIME type and dimensions.
|
||||||
|
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -399,8 +407,8 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
|||||||
|
|
||||||
req := &Request{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: Size{Width: 100, Height: 75},
|
||||||
Format: FormatWebP,
|
Format: format,
|
||||||
Quality: 80,
|
Quality: quality,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,29 +416,39 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
// Verify output is valid WebP
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
// Verify output format
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != "image/webp" {
|
if mime != wantMIME {
|
||||||
t.Errorf("Output format = %v, want image/webp", mime)
|
t.Errorf("Output format = %v, want %v", mime, wantMIME)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify dimensions
|
// Verify dimensions
|
||||||
if result.Width != 100 {
|
if result.Width != 100 {
|
||||||
t.Errorf("Width = %d, want 100", result.Width)
|
t.Errorf("Width = %d, want 100", result.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Height != 75 {
|
if result.Height != 75 {
|
||||||
t.Errorf("Height = %d, want 75", result.Height)
|
t.Errorf("Height = %d, want 75", result.Height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -452,7 +470,8 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
// Verify output is valid JPEG
|
// Verify output is valid JPEG
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
@@ -461,14 +480,17 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != "image/jpeg" {
|
if mime != mimeJPEG {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create a processor with a very small byte limit
|
// Create a processor with a very small byte limit
|
||||||
const limit = 1024
|
const limit = 1024
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: limit})
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -490,12 +512,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInputDataTooLarge {
|
if !errors.Is(err, ErrInputDataTooLarge) {
|
||||||
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
|
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create a small image and set limit well above its size
|
// Create a small image and set limit well above its size
|
||||||
input := createTestJPEG(t, 10, 10)
|
input := createTestJPEG(t, 10, 10)
|
||||||
limit := int64(len(input)) * 10 // 10× headroom
|
limit := int64(len(input)) * 10 // 10× headroom
|
||||||
@@ -514,10 +538,13 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Passing 0 should use the default
|
// Passing 0 should use the default
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
if proc.maxInputBytes != DefaultMaxInputBytes {
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
@@ -532,40 +559,156 @@ func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
proc := New(Params{})
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
||||||
|
|
||||||
req := &Request{
|
|
||||||
Size: Size{Width: 100, Height: 75},
|
|
||||||
Format: FormatAVIF,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
// processAndDecode runs input through Process and decodes the output with
|
||||||
|
// vips, so a test can inspect the image a client would receive.
|
||||||
|
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(
|
||||||
|
context.Background(), bytes.NewReader(input), req,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF encoding should work)", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
// Verify output is valid AVIF
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
output, err := vips.NewImageFromBuffer(data)
|
||||||
if mime != "image/avif" {
|
if err != nil {
|
||||||
t.Errorf("Output format = %v, want image/avif", mime)
|
t.Fatalf("failed to decode output: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify dimensions
|
t.Cleanup(output.Close)
|
||||||
if result.Width != 100 {
|
|
||||||
t.Errorf("Width = %d, want 100", result.Width)
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_StripsEXIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// gps-exif.jpg carries GPS coordinates, a camera make, model and serial
|
||||||
|
// number, and a capture time.
|
||||||
|
input, err := os.ReadFile("testdata/gps-exif.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture, err := vips.NewImageFromBuffer(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(fixture.Close)
|
||||||
|
|
||||||
|
if !slices.Contains(fixture.GetFields(), "exif-ifd3-GPSLatitude") {
|
||||||
|
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
||||||
|
}
|
||||||
|
|
||||||
|
formats := []Format{
|
||||||
|
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range formats {
|
||||||
|
t.Run(string(format), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: format})
|
||||||
|
|
||||||
|
if output.HasExif() {
|
||||||
|
t.Errorf("output has EXIF: %v", output.GetExif())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// orientation-6.jpg is stored 16x8, red on the left and blue on the
|
||||||
|
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
|
||||||
|
// Upright it is 8x16, red on top and blue below.
|
||||||
|
input, err := os.ReadFile("testdata/orientation-6.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size Size
|
||||||
|
wantW int
|
||||||
|
wantH int
|
||||||
|
}{
|
||||||
|
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
|
||||||
|
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: FormatPNG,
|
||||||
|
})
|
||||||
|
|
||||||
|
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
||||||
|
t.Fatalf("output is %dx%d, want %dx%d",
|
||||||
|
output.Width(), output.Height(), tt.wantW, tt.wantH)
|
||||||
|
}
|
||||||
|
|
||||||
|
top, err := output.GetPoint(tt.wantW/2, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottom, err := output.GetPoint(tt.wantW/2, tt.wantH-1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if top[0] <= top[2] || bottom[2] <= bottom[0] {
|
||||||
|
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
|
||||||
|
top, bottom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
|
||||||
|
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
|
||||||
|
input, err := os.ReadFile("testdata/display-p3.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: FormatPNG})
|
||||||
|
|
||||||
|
if output.HasICCProfile() {
|
||||||
|
t.Error("output has an ICC profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
pixel, err := output.GetPoint(4, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []float64{255, 0, 0}
|
||||||
|
for i := range want {
|
||||||
|
if math.Abs(pixel[i]-want[i]) > 5 {
|
||||||
|
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
|
||||||
}
|
}
|
||||||
if result.Height != 75 {
|
|
||||||
t.Errorf("Height = %d, want 75", result.Height)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/iotest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTestReadFailed is the error the unreadable test input returns.
|
||||||
|
var errTestReadFailed = errors.New("test input cannot be read")
|
||||||
|
|
||||||
|
// readingCounter counts the Process calls reading their input at the same
|
||||||
|
// time and remembers the most there ever were.
|
||||||
|
type readingCounter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
reading int
|
||||||
|
most int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readingCounter) start() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.reading++
|
||||||
|
c.most = max(c.most, c.reading)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readingCounter) stop() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.reading--
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readingCounter) mostReading() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.most
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatedReader is a Process input. Its first Read counts the call in,
|
||||||
|
// reports it on entered and blocks until gate is closed; it counts the call
|
||||||
|
// out when it returns io.EOF. Process reads its input only while it holds a
|
||||||
|
// processing slot, so the count never goes above MaxConcurrentProcessing.
|
||||||
|
type gatedReader struct {
|
||||||
|
data *bytes.Reader
|
||||||
|
gate <-chan struct{}
|
||||||
|
entered chan<- struct{}
|
||||||
|
counter *readingCounter
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *gatedReader) Read(p []byte) (int, error) {
|
||||||
|
if !r.started {
|
||||||
|
r.started = true
|
||||||
|
r.counter.start()
|
||||||
|
|
||||||
|
r.entered <- struct{}{}
|
||||||
|
|
||||||
|
<-r.gate
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.data.Read(p)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
r.counter.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// smallJPEGRequest asks for a 5x5 JPEG.
|
||||||
|
func smallJPEGRequest() *Request {
|
||||||
|
return &Request{
|
||||||
|
Size: Size{Width: 5, Height: 5},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processInBackground runs Process on reader in a new goroutine and sends
|
||||||
|
// its error on results.
|
||||||
|
func processInBackground(
|
||||||
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
|
) {
|
||||||
|
go func() {
|
||||||
|
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
||||||
|
if err == nil {
|
||||||
|
_ = result.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForEntries fails the test unless count Process calls report on
|
||||||
|
// entered within a few seconds.
|
||||||
|
func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for range count {
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Process calls did not start reading their input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, limit := range []int{0, -1} {
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
|
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
||||||
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
||||||
|
limit, got, runtime.GOMAXPROCS(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 3})
|
||||||
|
if got := cap(proc.processingSemaphore); got != 3 {
|
||||||
|
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
||||||
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
||||||
|
// Process until the test lets them go. No more than the limit may be
|
||||||
|
// working at once, and the calls held back must wait for a slot and then
|
||||||
|
// succeed.
|
||||||
|
func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
limit = 2
|
||||||
|
calls = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
|
input := createTestJPEG(t, 50, 50)
|
||||||
|
|
||||||
|
counter := &readingCounter{}
|
||||||
|
gate := make(chan struct{})
|
||||||
|
entered := make(chan struct{}, calls)
|
||||||
|
results := make(chan error, calls)
|
||||||
|
|
||||||
|
openGate := sync.OnceFunc(func() { close(gate) })
|
||||||
|
t.Cleanup(openGate)
|
||||||
|
|
||||||
|
for range calls {
|
||||||
|
processInBackground(proc, &gatedReader{
|
||||||
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
||||||
|
counter: counter,
|
||||||
|
}, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForEntries(t, entered, limit)
|
||||||
|
|
||||||
|
// A call beyond the limit would start reading its input now.
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
t.Fatalf("a Process call started while %d were already working", limit)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
openGate()
|
||||||
|
|
||||||
|
for range calls {
|
||||||
|
err := <-results
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Process() error = %v, want nil once a slot is free", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if most := counter.mostReading(); most > limit {
|
||||||
|
t.Errorf("%d Process calls worked at once, want at most %d", most, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessWaitsThenFailsWhenNoSlotFrees holds the only slot and checks
|
||||||
|
// that another call waits the whole wait timeout, then fails with
|
||||||
|
// ErrTooManyImages instead of processing anyway.
|
||||||
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
||||||
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
|
gate := make(chan struct{})
|
||||||
|
entered := make(chan struct{}, 1)
|
||||||
|
held := make(chan error, 1)
|
||||||
|
|
||||||
|
openGate := sync.OnceFunc(func() { close(gate) })
|
||||||
|
t.Cleanup(openGate)
|
||||||
|
|
||||||
|
processInBackground(proc, &gatedReader{
|
||||||
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
||||||
|
counter: &readingCounter{},
|
||||||
|
}, held)
|
||||||
|
waitForEntries(t, entered, 1)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
|
smallJPEGRequest())
|
||||||
|
if !errors.Is(err, ErrTooManyImages) {
|
||||||
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waited := time.Since(start); waited < proc.processingWaitTimeout {
|
||||||
|
t.Errorf("Process() failed after %v, before waiting %v",
|
||||||
|
waited, proc.processingWaitTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
openGate()
|
||||||
|
|
||||||
|
err = <-held
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Process() holding the slot: error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessReleasesSlotOnError checks that Process gives its slot back
|
||||||
|
// when it fails, whether it fails early or late: with one slot, the slot
|
||||||
|
// must be free after the failure and the next call must succeed.
|
||||||
|
func TestProcessReleasesSlotOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
|
unsupported := smallJPEGRequest()
|
||||||
|
unsupported.Format = "bmp"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input io.Reader
|
||||||
|
req *Request
|
||||||
|
// want is the error Process must return; nil means any error.
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "input cannot be read",
|
||||||
|
input: iotest.ErrReader(errTestReadFailed),
|
||||||
|
req: smallJPEGRequest(),
|
||||||
|
want: errTestReadFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input over the byte limit",
|
||||||
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
||||||
|
req: smallJPEGRequest(),
|
||||||
|
want: ErrInputDataTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input not an image",
|
||||||
|
input: strings.NewReader("not an image"),
|
||||||
|
req: smallJPEGRequest(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output format not supported",
|
||||||
|
input: bytes.NewReader(valid),
|
||||||
|
req: unsupported,
|
||||||
|
want: ErrUnsupportedOutputFormat,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
||||||
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||||
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(proc.processingSemaphore); held != 0 {
|
||||||
|
t.Fatalf("slot still held after the error: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
|
smallJPEGRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 811 B |
+397
-97
@@ -2,13 +2,20 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache errors.
|
// Cache errors.
|
||||||
@@ -20,17 +27,31 @@ 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
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
}
|
|
||||||
|
|
||||||
// variantMeta stores content type for fast cache hits without reading .meta file.
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
type variantMeta struct {
|
// enforces. Zero means no limit is enforced (no eviction). The
|
||||||
ContentType string
|
// config layer supplies the computed default when the operator
|
||||||
Size int64
|
// omits cache_max_bytes.
|
||||||
|
MaxBytes int64
|
||||||
|
|
||||||
|
// DisableDiskCache turns the disk cache off entirely: no cache
|
||||||
|
// directories are created, lookups always miss, stores are
|
||||||
|
// no-ops, and no eviction machinery runs. The config layer sets
|
||||||
|
// this when the operator configures cache_max_bytes: 0.
|
||||||
|
DisableDiskCache bool
|
||||||
|
|
||||||
|
// Logger receives accounting and eviction log output. A nil
|
||||||
|
// Logger means slog.Default().
|
||||||
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
@@ -40,36 +61,93 @@ type Cache struct {
|
|||||||
variants *VariantStorage // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
srcMetadata *MetadataStorage // source metadata by host/path
|
||||||
config CacheConfig
|
config CacheConfig
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
// disabled means the disk cache is turned off entirely: lookups
|
||||||
metaCache map[VariantKey]variantMeta
|
// always miss, stores are no-ops, and no eviction runs.
|
||||||
|
disabled bool
|
||||||
|
|
||||||
|
// Eviction machinery. The channels are created in NewCache so
|
||||||
|
// stores can signal write pressure without racing StartEviction.
|
||||||
|
evictionPressure chan struct{}
|
||||||
|
evictionStop chan struct{}
|
||||||
|
evictionDone chan struct{}
|
||||||
|
evictionStarted bool
|
||||||
|
evictionStopOnce sync.Once
|
||||||
|
|
||||||
|
// metaCache holds the content types of the variants most recently
|
||||||
|
// stored or served, so a hit does not read the variant's .meta file.
|
||||||
|
// It never stands in for the variant file, which is always opened.
|
||||||
|
metaCache *lru.Cache[VariantKey, string]
|
||||||
|
|
||||||
|
// contentLocks serializes StoreSource and evictSourceBlob per
|
||||||
|
// content hash, closing the race window between an eviction's row
|
||||||
|
// deletion and its file unlink against a concurrent store of
|
||||||
|
// identical content.
|
||||||
|
contentLocks *contentLock
|
||||||
|
|
||||||
|
// evictSourceBlobTestHook, when set, is invoked by evictSourceBlob
|
||||||
|
// after its row-deletion transaction commits and before the
|
||||||
|
// content file is unlinked. It exists solely so tests can
|
||||||
|
// deterministically pause inside that window to exercise
|
||||||
|
// concurrent stores against it; production code leaves it nil.
|
||||||
|
evictSourceBlobTestHook func(ContentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a new cache instance.
|
// NewCache creates a new cache instance.
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
log := config.Logger
|
||||||
|
if log == nil {
|
||||||
|
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{
|
||||||
|
db: db,
|
||||||
|
config: config,
|
||||||
|
log: log,
|
||||||
|
disabled: config.DisableDiskCache,
|
||||||
|
evictionPressure: make(chan struct{}, 1),
|
||||||
|
evictionStop: make(chan struct{}),
|
||||||
|
evictionDone: make(chan struct{}),
|
||||||
|
metaCache: metaCache,
|
||||||
|
contentLocks: newContentLock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.disabled {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srcContent, err := NewContentStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "sources"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants"))
|
variants, err := NewVariantStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "variants"), log,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata"))
|
srcMetadata, err := NewMetadataStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "metadata"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Cache{
|
c.srcContent = srcContent
|
||||||
db: db,
|
c.variants = variants
|
||||||
srcContent: srcContent,
|
c.srcMetadata = srcMetadata
|
||||||
variants: variants,
|
|
||||||
srcMetadata: srcMetadata,
|
return c, nil
|
||||||
config: config,
|
|
||||||
metaCache: make(map[VariantKey]variantMeta),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupResult contains the result of a cache lookup.
|
// LookupResult contains the result of a cache lookup.
|
||||||
@@ -81,12 +159,15 @@ type LookupResult struct {
|
|||||||
CacheStatus CacheStatus
|
CacheStatus CacheStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk (no DB access for hits).
|
// Lookup checks if a processed variant exists on disk. Hits touch the
|
||||||
func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, error) {
|
// variant's LRU timestamp; a disabled cache always misses.
|
||||||
|
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 variant storage directly - no DB needed for cache hits
|
||||||
if c.variants.Exists(cacheKey) {
|
if !c.disabled && c.variants.Exists(cacheKey) {
|
||||||
|
c.touchVariant(ctx, cacheKey)
|
||||||
|
|
||||||
return &LookupResult{
|
return &LookupResult{
|
||||||
Hit: true,
|
Hit: true,
|
||||||
CacheKey: cacheKey,
|
CacheKey: cacheKey,
|
||||||
@@ -101,27 +182,82 @@ func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, err
|
|||||||
}, 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. A variant with
|
||||||
|
// no .meta file is served as application/octet-stream, which is not
|
||||||
|
// kept.
|
||||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||||
return c.variants.LoadWithMeta(cacheKey)
|
if c.disabled {
|
||||||
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata.
|
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
|
||||||
|
// disabled cache it is a no-op returning an empty hash.
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *FetchResult,
|
result *httpfetcher.FetchResult,
|
||||||
) (ContentHash, error) {
|
) (ContentHash, error) {
|
||||||
// Store content
|
if c.disabled {
|
||||||
contentHash, size, err := c.srcContent.Store(content)
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the content ourselves (rather than via srcContent.Store,
|
||||||
|
// which would hash internally) so the content hash is known before
|
||||||
|
// any file or database work happens: that lets the entire store be
|
||||||
|
// serialized, per hash, against a concurrent eviction of the same
|
||||||
|
// content below.
|
||||||
|
data, err := io.ReadAll(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read source content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
contentHash := ContentHash(hex.EncodeToString(sum[:]))
|
||||||
|
|
||||||
|
// Hold the content hash's lock for the whole store operation. A
|
||||||
|
// concurrent eviction of this exact hash (the real SHA-256 dedup
|
||||||
|
// case: a different source path whose bytes hash identically)
|
||||||
|
// deletes the accounting rows and unlinks the file inside the same
|
||||||
|
// lock, so the two can never interleave: either this store
|
||||||
|
// completes first (and a subsequent eviction removes it together
|
||||||
|
// with its rows and file, correctly), or eviction completes first
|
||||||
|
// (and this store finds the file already gone and recreates it
|
||||||
|
// fresh) — never a fresh row left pointing at a file eviction is
|
||||||
|
// mid-unlink on.
|
||||||
|
unlock := c.contentLocks.Lock(string(contentHash))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
size, err := c.srcContent.StoreHashed(contentHash, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in database
|
// Store in database
|
||||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||||
headersJSON, _ := json.Marshal(result.Headers)
|
|
||||||
|
headersJSON, err := json.Marshal(result.Headers)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
||||||
@@ -149,39 +285,60 @@ func (c *Cache) StoreSource(
|
|||||||
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store metadata JSON file
|
c.writeMetadataSidecar(req, pathHash, contentHash, result)
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: req.SourceHost,
|
|
||||||
Path: req.SourcePath,
|
|
||||||
Query: req.SourceQuery,
|
|
||||||
ContentHash: string(contentHash),
|
|
||||||
StatusCode: result.StatusCode,
|
|
||||||
ContentType: result.ContentType,
|
|
||||||
ContentLength: result.ContentLength,
|
|
||||||
ResponseHeaders: result.Headers,
|
|
||||||
FetchedAt: time.Now().UTC().Unix(),
|
|
||||||
FetchDurationMs: result.FetchDurationMs,
|
|
||||||
RemoteAddr: result.RemoteAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.srcMetadata.Store(req.SourceHost, pathHash, meta); err != nil {
|
c.notifyWritePressure()
|
||||||
// Non-fatal, we have it in the database
|
|
||||||
_ = err
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentHash, nil
|
return contentHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key.
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
// it in the size accounting. On a disabled cache it is a no-op. The
|
||||||
_, err := c.variants.Store(cacheKey, content, contentType)
|
// accounting insert is best-effort (the startup reconciliation pass
|
||||||
|
// adopts any variant file that misses its accounting row).
|
||||||
|
func (c *Cache) StoreVariant(
|
||||||
|
ctx context.Context, cacheKey VariantKey, content io.Reader, contentType string,
|
||||||
|
) error {
|
||||||
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := c.variants.Store(cacheKey, content, contentType)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.metaCache.Add(cacheKey, contentType)
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
content_type = excluded.content_type,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(cacheKey), size, contentType)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to record variant in size accounting",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifyWritePressure()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values if not.
|
// Returns the content hash and content type if found, or empty values
|
||||||
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
|
||||||
|
// reports no cached source.
|
||||||
|
func (c *Cache) LookupSource(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (ContentHash, string, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
var hashStr, contentType string
|
var hashStr, contentType string
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
@@ -204,15 +361,21 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
|
|||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.touchSourceContent(ctx, contentHash)
|
||||||
|
|
||||||
return contentHash, contentType, nil
|
return contentHash, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error {
|
func (c *Cache) StoreNegative(
|
||||||
|
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
|
||||||
|
) error {
|
||||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at)
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, source_query, status_code,
|
||||||
|
error_message, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
||||||
status_code = excluded.status_code,
|
status_code = excluded.status_code,
|
||||||
@@ -227,46 +390,16 @@ func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkNegativeCache checks if a request is in the negative cache.
|
|
||||||
func (c *Cache) checkNegativeCache(ctx context.Context, req *ImageRequest) (bool, error) {
|
|
||||||
var expiresAt time.Time
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT expires_at FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if time.Now().After(expiresAt) {
|
|
||||||
// Clean up expired entry
|
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
// GetSourceMetadataID returns the source metadata ID for a request.
|
||||||
func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int64, error) {
|
func (c *Cache) GetSourceMetadataID(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT id FROM source_metadata
|
SELECT id FROM source_metadata
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -274,9 +407,16 @@ func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSourceContent returns a reader for cached source content by its hash.
|
// GetSourceContent returns a reader for cached source content by its hash,
|
||||||
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
|
// and the content's size in bytes.
|
||||||
return c.srcContent.Load(contentHash)
|
func (c *Cache) GetSourceContent(
|
||||||
|
contentHash ContentHash,
|
||||||
|
) (io.ReadCloser, int64, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return nil, 0, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.srcContent.LoadWithSize(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired removes expired entries from the cache.
|
// CleanExpired removes expired entries from the cache.
|
||||||
@@ -306,9 +446,22 @@ 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
|
||||||
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_cache`).Scan(&stats.TotalItems)
|
// disabled cache holds none, whatever rows an earlier run left.
|
||||||
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`).Scan(&stats.TotalSizeBytes)
|
if !c.disabled {
|
||||||
|
err = c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT (SELECT COUNT(*) FROM source_content)
|
||||||
|
+ (SELECT COUNT(*) FROM variant_content)
|
||||||
|
`).Scan(&stats.TotalItems)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute hit rate as a ratio
|
// Compute hit rate as a ratio
|
||||||
if stats.HitCount+stats.MissCount > 0 {
|
if stats.HitCount+stats.MissCount > 0 {
|
||||||
@@ -320,23 +473,170 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
|
|
||||||
// IncrementStats increments cache statistics.
|
// IncrementStats increments cache statistics.
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||||
|
var err error
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET hit_count = hit_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET hit_count = hit_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET miss_count = miss_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET miss_count = miss_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
if fetchBytes > 0 {
|
if fetchBytes > 0 {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
SET upstream_fetch_count = upstream_fetch_count + 1,
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`, fetchBytes)
|
`, fetchBytes)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count upstream fetch",
|
||||||
|
"fetch_bytes", fetchBytes, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
|
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE cache_stats
|
||||||
|
SET transform_count = transform_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count transform", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||||
|
// hold: it reads the content type from the variant's .meta file and
|
||||||
|
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||||
|
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
||||||
|
// as one can between a store's writing of the variant file and of its
|
||||||
|
// .meta file, serves application/octet-stream and keeps nothing, so
|
||||||
|
// metaCache only ever holds a type read from a .meta file or passed to
|
||||||
|
// StoreVariant.
|
||||||
|
func (c *Cache) loadVariantWithMeta(
|
||||||
|
cacheKey VariantKey,
|
||||||
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType == "" {
|
||||||
|
return reader, size, fallbackContentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
||||||
|
|
||||||
|
return reader, size, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
||||||
|
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
||||||
|
// database.
|
||||||
|
func (c *Cache) writeMetadataSidecar(
|
||||||
|
req *ImageRequest,
|
||||||
|
pathHash PathHash,
|
||||||
|
contentHash ContentHash,
|
||||||
|
result *httpfetcher.FetchResult,
|
||||||
|
) {
|
||||||
|
meta := &SourceMetadata{
|
||||||
|
Host: req.SourceHost,
|
||||||
|
Path: req.SourcePath,
|
||||||
|
Query: req.SourceQuery,
|
||||||
|
ContentHash: string(contentHash),
|
||||||
|
StatusCode: result.StatusCode,
|
||||||
|
ContentType: result.ContentType,
|
||||||
|
ContentLength: result.ContentLength,
|
||||||
|
ResponseHeaders: result.Headers,
|
||||||
|
FetchedAt: time.Now().UTC().Unix(),
|
||||||
|
FetchDurationMs: result.FetchDurationMs,
|
||||||
|
RemoteAddr: result.RemoteAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to write metadata sidecar",
|
||||||
|
"host", req.SourceHost, "path_hash", pathHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchVariant updates the LRU timestamp of a variant, best-effort:
|
||||||
|
// a failed touch only makes the entry look colder to eviction.
|
||||||
|
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE cache_key = ?
|
||||||
|
`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("failed to touch variant LRU timestamp",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchSourceContent updates the LRU timestamp of a source content
|
||||||
|
// blob, best-effort: a failed touch only makes the blob look colder.
|
||||||
|
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE content_hash = ?
|
||||||
|
`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("failed to touch source content LRU timestamp",
|
||||||
|
"content_hash", contentHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNegativeCache checks if a request is in the negative cache.
|
||||||
|
func (c *Cache) checkNegativeCache(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (bool, error) {
|
||||||
|
var expiresAt time.Time
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT expires_at FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
// Clean up expired entry
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to delete expired negative cache entry",
|
||||||
|
"host", req.SourceHost, "path", req.SourcePath, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -85,14 +86,15 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
INSERT INTO cache_stats (id) VALUES (1);
|
INSERT INTO cache_stats (id) VALUES (1);
|
||||||
`
|
`
|
||||||
|
|
||||||
if _, err := db.Exec(schema); err != nil {
|
_, err = db.ExecContext(t.Context(), schema)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to create schema: %v", err)
|
t.Fatalf("failed to create schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTestCache(t *testing.T) (*Cache, string) {
|
func setupTestCache(t *testing.T) *Cache {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -107,16 +109,18 @@ func setupTestCache(t *testing.T) (*Cache, string) {
|
|||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache, tmpDir
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_LookupMiss(t *testing.T) {
|
func TestCache_LookupMiss(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -138,12 +142,14 @@ func TestCache_LookupMiss(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_StoreAndLookup(t *testing.T) {
|
func TestCache_StoreAndLookup(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -152,12 +158,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
sourceContent := []byte("fake jpeg data")
|
sourceContent := []byte("fake jpeg data")
|
||||||
fetchResult := &FetchResult{
|
fetchResult := &httpfetcher.FetchResult{
|
||||||
ContentType: "image/jpeg",
|
ContentType: testContentTypeJPEG,
|
||||||
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHash, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
contentHash, err := cache.StoreSource(
|
||||||
|
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource() error = %v", err)
|
t.Fatalf("StoreSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,7 +176,9 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("fake webp data")
|
outputContent := []byte("fake webp data")
|
||||||
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err = cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -194,11 +203,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_NegativeCache(t *testing.T) {
|
func TestCache_NegativeCache(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/notfound.jpg",
|
SourcePath: "/photos/notfound.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -222,6 +233,8 @@ func TestCache_NegativeCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -238,7 +251,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/expired.jpg",
|
SourcePath: "/photos/expired.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -265,11 +278,13 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_VariantLookup(t *testing.T) {
|
func TestCache_VariantLookup(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/variant.jpg",
|
SourcePath: "/photos/variant.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -280,7 +295,9 @@ func TestCache_VariantLookup(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("output data")
|
outputContent := []byte("output data")
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -311,11 +328,13 @@ func TestCache_VariantLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/variantct.jpg",
|
SourcePath: "/photos/variantct.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -326,7 +345,9 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("output webp data")
|
outputContent := []byte("output webp data")
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -346,7 +367,8 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
t.Fatalf("GetVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
if contentType != "image/webp" {
|
if contentType != "image/webp" {
|
||||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
||||||
@@ -358,11 +380,13 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_GetVariant(t *testing.T) {
|
func TestCache_GetVariant(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/output.jpg",
|
SourcePath: "/photos/output.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -373,7 +397,9 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("the actual output content")
|
outputContent := []byte("the actual output content")
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -389,7 +415,8 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
t.Fatalf("GetVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
buf := make([]byte, 100)
|
buf := make([]byte, 100)
|
||||||
n, _ := reader.Read(buf)
|
n, _ := reader.Read(buf)
|
||||||
@@ -400,7 +427,9 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_Stats(t *testing.T) {
|
func TestCache_Stats(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Increment some stats
|
// Increment some stats
|
||||||
@@ -423,6 +452,8 @@ func TestCache_Stats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_CleanExpired(t *testing.T) {
|
func TestCache_CleanExpired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -435,7 +466,8 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
|
|
||||||
// Insert expired negative cache entry directly
|
// Insert expired negative cache entry directly
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, expires_at)
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, source_query, status_code, expires_at)
|
||||||
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -444,7 +476,12 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
|
|
||||||
// Verify it exists
|
// Verify it exists
|
||||||
var count int
|
var count int
|
||||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
|
||||||
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
||||||
}
|
}
|
||||||
@@ -456,13 +493,19 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's gone
|
// Verify it's gone
|
||||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -482,7 +525,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
path := tmpDir + "/" + dir
|
path := tmpDir + "/" + dir
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
t.Errorf("directory %s was not created", dir)
|
t.Errorf("directory %s was not created", dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// contentLock provides per-key mutual exclusion for content-hash keyed
|
||||||
|
// operations. StoreSource and evictSourceBlob each hold a content
|
||||||
|
// hash's lock for the full duration of their file-plus-accounting-row
|
||||||
|
// work, so a store and an eviction racing on identical content bytes
|
||||||
|
// (the real SHA-256 content-addressed dedup case, not a contrived one)
|
||||||
|
// can never interleave: the unlink of an evicted blob's file can never
|
||||||
|
// race the creation of a fresh database row for a concurrently
|
||||||
|
// re-stored copy of the same content. Entries are removed once no
|
||||||
|
// goroutine holds or is waiting for them, so a long-running process
|
||||||
|
// does not accumulate memory proportional to the number of distinct
|
||||||
|
// content hashes it has ever seen.
|
||||||
|
type contentLock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*contentLockEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentLockEntry is one key's exclusion lock plus a count of
|
||||||
|
// goroutines currently holding or waiting to acquire it, used to know
|
||||||
|
// when it is safe to remove the entry from the map.
|
||||||
|
type contentLockEntry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newContentLock creates an empty contentLock.
|
||||||
|
func newContentLock() *contentLock {
|
||||||
|
return &contentLock{entries: make(map[string]*contentLockEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock acquires exclusive access for key, blocking until it is
|
||||||
|
// available, and returns a function that releases it. The caller must
|
||||||
|
// invoke the returned function exactly once to release the lock.
|
||||||
|
func (c *contentLock) Lock(key string) func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok {
|
||||||
|
entry = &contentLockEntry{}
|
||||||
|
c.entries[key] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
entry.mu.Lock()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
entry.count--
|
||||||
|
if entry.count == 0 {
|
||||||
|
delete(c.entries, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestContentLockExcludesSameKey verifies that two goroutines locking
|
||||||
|
// the same key never run their critical sections concurrently.
|
||||||
|
func TestContentLockExcludesSameKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lock := newContentLock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
active atomic.Int32
|
||||||
|
maxSeen int32
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
const goroutines = 20
|
||||||
|
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
for range goroutines {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
unlock := lock.Lock("same-key")
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
n := active.Add(1)
|
||||||
|
|
||||||
|
for {
|
||||||
|
seen := atomic.LoadInt32(&maxSeen)
|
||||||
|
if n <= seen || atomic.CompareAndSwapInt32(&maxSeen, seen, n) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
|
||||||
|
active.Add(-1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if maxSeen != 1 {
|
||||||
|
t.Errorf("max concurrent holders of the same key = %d, want 1", maxSeen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentLockAllowsDifferentKeys verifies that locking distinct
|
||||||
|
// keys does not serialize unrelated work: all goroutines must be able
|
||||||
|
// to enter their critical sections at once, proven by every one of
|
||||||
|
// them reaching the rendezvous point before any is allowed to
|
||||||
|
// proceed.
|
||||||
|
func TestContentLockAllowsDifferentKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lock := newContentLock()
|
||||||
|
|
||||||
|
const goroutines = 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
inside atomic.Int32
|
||||||
|
reached = make(chan struct{}, goroutines)
|
||||||
|
)
|
||||||
|
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
for i := range goroutines {
|
||||||
|
key := string(rune('a' + i))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
unlock := lock.Lock(key)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
inside.Add(1)
|
||||||
|
|
||||||
|
reached <- struct{}{}
|
||||||
|
|
||||||
|
<-release
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every goroutine must reach the rendezvous point (i.e. acquire its
|
||||||
|
// own key's lock) without needing any other to release first. If
|
||||||
|
// keys were incorrectly serialized onto one underlying lock, only
|
||||||
|
// one would get here and this would time out.
|
||||||
|
for i := range goroutines {
|
||||||
|
select {
|
||||||
|
case <-reached:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("only %d/%d goroutines locking distinct keys made progress; "+
|
||||||
|
"keys may be incorrectly serialized", i, goroutines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := inside.Load(); n != goroutines {
|
||||||
|
t.Errorf("goroutines inside their critical section = %d, want %d",
|
||||||
|
n, goroutines)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentLockRemovesEntryAfterUnlock verifies that the internal
|
||||||
|
// entries map does not grow without bound: once no goroutine holds or
|
||||||
|
// awaits a key, its entry is removed.
|
||||||
|
func TestContentLockRemovesEntryAfterUnlock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lock := newContentLock()
|
||||||
|
|
||||||
|
unlock := lock.Lock("k")
|
||||||
|
|
||||||
|
lock.mu.Lock()
|
||||||
|
if _, ok := lock.entries["k"]; !ok {
|
||||||
|
lock.mu.Unlock()
|
||||||
|
t.Fatal("entry missing while lock is held")
|
||||||
|
}
|
||||||
|
lock.mu.Unlock()
|
||||||
|
|
||||||
|
unlock()
|
||||||
|
|
||||||
|
lock.mu.Lock()
|
||||||
|
defer lock.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := lock.entries["k"]; ok {
|
||||||
|
t.Error("entry for key still present after the last holder unlocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Simulate the calculation from processAndStore
|
// Simulate the calculation from processAndStore
|
||||||
fetchBytes := int64(0)
|
fetchBytes := int64(0)
|
||||||
outputSize := int64(100)
|
outputSize := int64(100)
|
||||||
@@ -29,6 +31,8 @@ func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSizePercentNormalCase(t *testing.T) {
|
func TestSizePercentNormalCase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fetchBytes := int64(1000)
|
fetchBytes := int64(1000)
|
||||||
outputSize := int64(500)
|
outputSize := int64(500)
|
||||||
|
|
||||||
@@ -0,0 +1,807 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultEvictionInterval is how often the background evictor checks
|
||||||
|
// cache usage against the configured limit, in addition to the
|
||||||
|
// write-pressure wakeups triggered by stores.
|
||||||
|
const DefaultEvictionInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// evictionBatchSize is how many LRU candidates of each class (variants
|
||||||
|
// and source blobs) one eviction pass fetches from the database.
|
||||||
|
const evictionBatchSize = 100
|
||||||
|
|
||||||
|
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
||||||
|
// crashed write) must be before reconciliation removes it. Fresh temp
|
||||||
|
// files may still belong to an in-flight store.
|
||||||
|
const staleTempFileAge = time.Hour
|
||||||
|
|
||||||
|
// sqliteTimestampLayout matches SQLite's CURRENT_TIMESTAMP format, so
|
||||||
|
// timestamps written by reconciliation order correctly against ones
|
||||||
|
// written by the hot path.
|
||||||
|
const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
|
// tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache
|
||||||
|
// writes (".tmp-*" patterns in the storage layer).
|
||||||
|
const tempFilePrefix = ".tmp-"
|
||||||
|
|
||||||
|
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
||||||
|
// to each variant file.
|
||||||
|
const variantMetaSuffix = ".meta"
|
||||||
|
|
||||||
|
// fallbackContentType is the content type given to a variant file that
|
||||||
|
// has no readable .meta sidecar, when it is served or reconciled.
|
||||||
|
const fallbackContentType = "application/octet-stream"
|
||||||
|
|
||||||
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
|
// tracked in the database (source content blobs plus processed
|
||||||
|
// variants). It never scans the cache directories.
|
||||||
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
||||||
|
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
||||||
|
`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
||||||
|
// processed variant (isVariant true, identified by cacheKey) or a
|
||||||
|
// source content blob (identified by contentHash).
|
||||||
|
type evictionCandidate struct {
|
||||||
|
isVariant bool
|
||||||
|
cacheKey VariantKey
|
||||||
|
contentHash ContentHash
|
||||||
|
sizeBytes int64
|
||||||
|
lastAccessedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictToLimit evicts least-recently-used cache entries until total
|
||||||
|
// tracked usage is at or below the configured MaxBytes limit. It is a
|
||||||
|
// no-op when the cache is disabled or no limit is configured.
|
||||||
|
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
||||||
|
if c.disabled || c.config.MaxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
usage, err := c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage <= c.config.MaxBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
freed, err := c.evictBatch(ctx, usage-c.config.MaxBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if freed == 0 {
|
||||||
|
c.log.Warn("cache eviction made no progress",
|
||||||
|
"usage_bytes", usage,
|
||||||
|
"cache_max_bytes", c.config.MaxBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("evicted cache content",
|
||||||
|
"freed_bytes", freed,
|
||||||
|
"usage_bytes", usage-freed,
|
||||||
|
"cache_max_bytes", c.config.MaxBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictBatch fetches one batch of LRU candidates across variants and
|
||||||
|
// source blobs and evicts them oldest-first until excessBytes are
|
||||||
|
// freed or the batch is exhausted. It returns the bytes freed.
|
||||||
|
func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error) {
|
||||||
|
candidates, err := c.evictionCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var freed int64
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if freed >= excessBytes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.evictCandidate(ctx, candidate)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to evict cache entry",
|
||||||
|
"cache_key", candidate.cacheKey,
|
||||||
|
"content_hash", candidate.contentHash,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
freed += candidate.sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return freed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictCandidate removes a single eviction victim.
|
||||||
|
func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate) error {
|
||||||
|
if candidate.isVariant {
|
||||||
|
return c.evictVariant(ctx, candidate.cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.evictSourceBlob(ctx, candidate.contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionCandidates returns up to evictionBatchSize variants and
|
||||||
|
// evictionBatchSize source blobs, merged into a single list ordered by
|
||||||
|
// last access time (oldest first).
|
||||||
|
func (c *Cache) evictionCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
variants, err := c.variantCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := c.sourceCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the two lists, each already sorted oldest-first. SQLite
|
||||||
|
// CURRENT_TIMESTAMP strings compare correctly lexicographically.
|
||||||
|
merged := make([]evictionCandidate, 0, len(variants)+len(sources))
|
||||||
|
|
||||||
|
for len(variants) > 0 && len(sources) > 0 {
|
||||||
|
if variants[0].lastAccessedAt <= sources[0].lastAccessedAt {
|
||||||
|
merged = append(merged, variants[0])
|
||||||
|
variants = variants[1:]
|
||||||
|
} else {
|
||||||
|
merged = append(merged, sources[0])
|
||||||
|
sources = sources[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, variants...)
|
||||||
|
merged = append(merged, sources...)
|
||||||
|
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCandidates returns the least recently used variants.
|
||||||
|
func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT cache_key, size_bytes, last_accessed_at
|
||||||
|
FROM variant_content
|
||||||
|
ORDER BY last_accessed_at ASC, cache_key ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query variant eviction candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var candidates []evictionCandidate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
candidate := evictionCandidate{isVariant: true}
|
||||||
|
|
||||||
|
var key string
|
||||||
|
|
||||||
|
err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.cacheKey = VariantKey(key)
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("variant candidate iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceCandidates returns the least recently used source blobs. Rows
|
||||||
|
// written before the LRU column existed fall back to fetched_at.
|
||||||
|
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT content_hash, size_bytes,
|
||||||
|
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
||||||
|
FROM source_content
|
||||||
|
ORDER BY lru ASC, content_hash ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var candidates []evictionCandidate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate evictionCandidate
|
||||||
|
|
||||||
|
var hash string
|
||||||
|
|
||||||
|
err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.contentHash = ContentHash(hash)
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("source candidate iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
|
// content and .meta files, so the database never references a deleted
|
||||||
|
// 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 {
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.metaCache.Remove(cacheKey)
|
||||||
|
|
||||||
|
err = c.variants.DeleteWithMeta(cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceReference identifies one source_metadata row's JSON sidecar.
|
||||||
|
type sourceReference struct {
|
||||||
|
host string
|
||||||
|
pathHash PathHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictSourceBlob removes one source content blob. All source_metadata
|
||||||
|
// rows referencing the blob are deleted together with its
|
||||||
|
// source_content row in a single transaction BEFORE the file is
|
||||||
|
// unlinked: a blob referenced by multiple source paths is only ever
|
||||||
|
// removed together with all of its references, and database rows never
|
||||||
|
// point at deleted files. The JSON metadata sidecars for the removed
|
||||||
|
// rows are deleted afterwards.
|
||||||
|
//
|
||||||
|
// The whole operation holds the content hash's lock (the same one
|
||||||
|
// StoreSource holds for its full store), so a concurrent store of
|
||||||
|
// identical content bytes can never observe the file gone but a row
|
||||||
|
// still present, or insert a fresh row between this transaction's
|
||||||
|
// commit and the file unlink below: it either runs entirely before
|
||||||
|
// this eviction starts, or is blocked until this eviction (row
|
||||||
|
// deletion and unlink together) has fully completed.
|
||||||
|
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
||||||
|
unlock := c.contentLocks.Lock(string(contentHash))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
references, err := c.sourceReferences(ctx, contentHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.evictSourceBlobTestHook != nil {
|
||||||
|
c.evictSourceBlobTestHook(contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only after the rows are gone may the files be removed.
|
||||||
|
for _, reference := range references {
|
||||||
|
err := c.srcMetadata.Delete(reference.host, reference.pathHash)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to delete metadata sidecar",
|
||||||
|
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.srcContent.Delete(contentHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceReferences lists the metadata sidecar locations of every
|
||||||
|
// source_metadata row referencing the given blob.
|
||||||
|
func (c *Cache) sourceReferences(
|
||||||
|
ctx context.Context, contentHash ContentHash,
|
||||||
|
) ([]sourceReference, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
||||||
|
`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var references []sourceReference
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var reference sourceReference
|
||||||
|
|
||||||
|
var pathHash string
|
||||||
|
|
||||||
|
err := rows.Scan(&reference.host, &pathHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reference.pathHash = PathHash(pathHash)
|
||||||
|
references = append(references, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return references, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWritePressure wakes the background evictor after a store, so
|
||||||
|
// eviction under write pressure happens promptly without blocking the
|
||||||
|
// storing request. The notification channel has capacity one and drops
|
||||||
|
// when a wakeup is already pending.
|
||||||
|
func (c *Cache) notifyWritePressure() {
|
||||||
|
if c.disabled || c.config.MaxBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.evictionPressure <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartEviction launches the background eviction goroutine, which
|
||||||
|
// reconciles the database accounting with the cache directories at
|
||||||
|
// startup and again on every periodic tick thereafter, and evicts to
|
||||||
|
// the configured limit on the given periodic interval and on
|
||||||
|
// write-pressure notifications. It is a no-op on a disabled cache or
|
||||||
|
// when already started.
|
||||||
|
func (c *Cache) StartEviction(interval time.Duration) {
|
||||||
|
if c.disabled || c.evictionStarted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.evictionStarted = true
|
||||||
|
|
||||||
|
go c.evictionLoop(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopEviction stops the background eviction goroutine and waits for
|
||||||
|
// it to exit. It is safe to call when eviction was never started, and
|
||||||
|
// safe to call more than once.
|
||||||
|
func (c *Cache) StopEviction() {
|
||||||
|
if !c.evictionStarted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.evictionStopOnce.Do(func() {
|
||||||
|
close(c.evictionStop)
|
||||||
|
<-c.evictionDone
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionLoop is the body of the background eviction goroutine.
|
||||||
|
func (c *Cache) evictionLoop(interval time.Duration) {
|
||||||
|
defer close(c.evictionDone)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
c.runReconciliationPass(ctx)
|
||||||
|
c.runEvictionPass(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.evictionStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Reconciliation walks the cache directories, so it only
|
||||||
|
// runs on the periodic ticker rather than on every
|
||||||
|
// write-pressure wakeup, keeping it off the per-store hot
|
||||||
|
// path. Reusing the eviction interval itself (rather than a
|
||||||
|
// separate, longer one) is a deliberate choice: it is the
|
||||||
|
// simplest option that still bounds how long a store's
|
||||||
|
// best-effort accounting insert can stay silently
|
||||||
|
// unaccounted for to one interval, on a process that is
|
||||||
|
// already running this loop regardless.
|
||||||
|
c.runReconciliationPass(ctx)
|
||||||
|
case <-c.evictionPressure:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.runEvictionPass(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEvictionPass runs one eviction pass, logging failures instead of
|
||||||
|
// propagating them (the loop must keep running).
|
||||||
|
func (c *Cache) runEvictionPass(ctx context.Context) {
|
||||||
|
err := c.EvictToLimit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("cache eviction pass failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runReconciliationPass runs one reconciliation pass, logging failures
|
||||||
|
// instead of propagating them (the loop must keep running).
|
||||||
|
func (c *Cache) runReconciliationPass(ctx context.Context) {
|
||||||
|
err := c.reconcileAccounting(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileAccounting synchronizes the database size accounting with
|
||||||
|
// the actual contents of the cache directories. It runs at startup and
|
||||||
|
// again on every periodic eviction tick thereafter, off the request
|
||||||
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
|
// files are missing, removes source blob files the database does not
|
||||||
|
// know (and rows whose files are gone), and sweeps stale temp files
|
||||||
|
// left behind by crashed writes. Running it periodically, not just
|
||||||
|
// once, bounds how long such drift can accumulate unaccounted for on a
|
||||||
|
// long-running process to one eviction interval.
|
||||||
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.reconcileVariantFiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.reconcileVariantRows(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.reconcileSourceFiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.reconcileSourceRows(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantFiles walks the variant storage directory, adopting
|
||||||
|
// files without accounting rows and sweeping stale temp files.
|
||||||
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
|
return filepath.WalkDir(
|
||||||
|
c.variants.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
if strings.HasPrefix(name, tempFilePrefix) {
|
||||||
|
c.sweepStaleTempFile(path, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(name, variantMetaSuffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
|
// has none, using the file's size and modification time.
|
||||||
|
func (c *Cache) adoptVariantFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
||||||
|
) error {
|
||||||
|
var rowExists int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
||||||
|
).Scan(&rowExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat variant file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
|
contentType := c.variantContentTypeFromSidecar(path)
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content
|
||||||
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("adopted untracked variant file into size accounting",
|
||||||
|
"cache_key", cacheKey, "size_bytes", info.Size())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantContentTypeFromSidecar reads the content type from a variant
|
||||||
|
// .meta sidecar, falling back to application/octet-stream.
|
||||||
|
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
||||||
|
//nolint:gosec // path from cache walk
|
||||||
|
metaData, err := os.ReadFile(variantPath + variantMetaSuffix)
|
||||||
|
if err != nil {
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta VariantMeta
|
||||||
|
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
|
// missing, so the database never references deleted content.
|
||||||
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
|
keys, err := c.allVariantKeys(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if c.variants.Exists(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allVariantKeys returns every tracked variant cache key.
|
||||||
|
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
|
||||||
|
return queryStringColumn[VariantKey](ctx, c.db,
|
||||||
|
`SELECT cache_key FROM variant_content`, "variant keys", "variant key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryStringColumn runs a single-column query and returns the column
|
||||||
|
// values as T. plural names the set for the query and scan failure
|
||||||
|
// messages; singular names one row for the scan and iteration failure
|
||||||
|
// messages.
|
||||||
|
func queryStringColumn[T ~string](
|
||||||
|
ctx context.Context, db *sql.DB, query, plural, singular string,
|
||||||
|
) ([]T, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var values []T
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
err := rows.Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan %s: %w", singular, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, T(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s iteration failed: %w", singular, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileSourceFiles walks the source content directory, removing
|
||||||
|
// blob files the database does not track (they are unreachable: source
|
||||||
|
// lookups always go through source_metadata) and sweeping stale temp
|
||||||
|
// files.
|
||||||
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
|
return filepath.WalkDir(
|
||||||
|
c.srcContent.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
if strings.HasPrefix(name, tempFilePrefix) {
|
||||||
|
c.sweepStaleTempFile(path, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
|
// source_content row. Any source_metadata rows referencing the hash
|
||||||
|
// are removed first so no row ever points at a deleted file.
|
||||||
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
|
ctx context.Context, path string, contentHash ContentHash,
|
||||||
|
) error {
|
||||||
|
var rowExists int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
||||||
|
).Scan(&rowExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileSourceRows removes source_content rows (and their metadata
|
||||||
|
// references and sidecars) whose blob files are missing on disk.
|
||||||
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
||||||
|
hashes, err := c.allSourceContentHashes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hash := range hashes {
|
||||||
|
if c.srcContent.Exists(hash) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blob file is already gone; evictSourceBlob removes the
|
||||||
|
// rows and sidecars and tolerates the missing file.
|
||||||
|
err := c.evictSourceBlob(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allSourceContentHashes returns every tracked source content hash.
|
||||||
|
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
||||||
|
return queryStringColumn[ContentHash](ctx, c.db,
|
||||||
|
`SELECT content_hash FROM source_content`,
|
||||||
|
"source content hashes", "content hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepStaleTempFile removes a temp file left behind by a crashed
|
||||||
|
// write once it is old enough that no in-flight store can own it.
|
||||||
|
func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(info.ModTime()) < staleTempFileAge {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("removed stale temp file", "path", path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,985 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
||||||
|
// produces, so injected timestamps compare correctly against ones the
|
||||||
|
// implementation writes.
|
||||||
|
const sqliteTimestampFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
|
// testVariantKeyOne through testVariantKeyFour are the variant cache
|
||||||
|
// keys reused across the eviction tests, in the order the tests store
|
||||||
|
// them.
|
||||||
|
const (
|
||||||
|
testVariantKeyOne VariantKey = "aabbccdd0001"
|
||||||
|
testVariantKeyTwo VariantKey = "aabbccdd0002"
|
||||||
|
testVariantKeyThree VariantKey = "aabbccdd0003"
|
||||||
|
testVariantKeyFour VariantKey = "aabbccdd0004"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evictionTestDB creates an in-memory SQLite database with the real
|
||||||
|
// production schema, limited to a single connection so the background
|
||||||
|
// eviction goroutine shares the same in-memory database as the test.
|
||||||
|
func evictionTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEvictionTestCache creates a Cache backed by a temp directory and
|
||||||
|
// an in-memory database, with the given size limit.
|
||||||
|
func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
db := evictionTestDB(t)
|
||||||
|
|
||||||
|
// maxBytes zero mirrors the production mapping of
|
||||||
|
// cache_max_bytes: 0 (handlers sets DisableDiskCache); at the
|
||||||
|
// CacheConfig layer itself a zero MaxBytes means "no limit" for
|
||||||
|
// backwards compatibility with existing fixtures.
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: tmpDir,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
MaxBytes: maxBytes,
|
||||||
|
DisableDiskCache: maxBytes == 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache, tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeEvictionTestSource stores content as a fetched source for
|
||||||
|
// host/path and returns the resulting content hash.
|
||||||
|
func storeEvictionTestSource(
|
||||||
|
t *testing.T, cache *Cache, host, path string, content []byte,
|
||||||
|
) ContentHash {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: host,
|
||||||
|
SourcePath: path,
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &httpfetcher.FetchResult{
|
||||||
|
StatusCode: 200,
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
ContentLength: int64(len(content)),
|
||||||
|
Headers: map[string][]string{
|
||||||
|
testHeaderContentType: {testContentTypeJPEG},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := cache.StoreSource(
|
||||||
|
context.Background(), req, bytes.NewReader(content), result,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeEvictionTestVariant stores content as a processed variant under
|
||||||
|
// the given cache key.
|
||||||
|
func storeEvictionTestVariant(
|
||||||
|
t *testing.T, cache *Cache, key VariantKey, content []byte,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := cache.StoreVariant(t.Context(), key, bytes.NewReader(content), "image/webp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setVariantLastAccessed backdates the last access time of a tracked
|
||||||
|
// variant, to make LRU ordering deterministic in tests.
|
||||||
|
func setVariantLastAccessed(
|
||||||
|
t *testing.T, cache *Cache, key VariantKey, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := cache.db.ExecContext(t.Context(),
|
||||||
|
`UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set variant last_accessed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read affected rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("variant %s has no accounting row (affected=%d); "+
|
||||||
|
"stores must track variants in the database", key, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
|
// source content blob.
|
||||||
|
func setSourceLastAccessed(
|
||||||
|
t *testing.T, cache *Cache, hash ContentHash, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := cache.db.ExecContext(t.Context(),
|
||||||
|
`UPDATE source_content SET last_accessed_at = ? WHERE content_hash = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(hash),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read affected rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRows returns the number of rows the given query yields.
|
||||||
|
func countRows(t *testing.T, cache *Cache, query string, args ...any) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
|
||||||
|
err := cache.db.QueryRowContext(t.Context(), query, args...).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count query %q failed: %v", query, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoDanglingReferences verifies the core eviction invariant:
|
||||||
|
// every database row that references cache content on disk points at a
|
||||||
|
// file that actually exists.
|
||||||
|
func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := cache.db.QueryContext(t.Context(),
|
||||||
|
`SELECT content_hash FROM source_metadata
|
||||||
|
WHERE content_hash IS NOT NULL AND content_hash != ''`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query source_metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var hash string
|
||||||
|
|
||||||
|
err := rows.Scan(&hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to scan content_hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.srcContent.Exists(ContentHash(hash)) {
|
||||||
|
t.Errorf("source_metadata references content %s but the file is missing",
|
||||||
|
hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("source_metadata iteration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
variantRows, err := cache.db.QueryContext(t.Context(),
|
||||||
|
`SELECT cache_key FROM variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query variant_content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = variantRows.Close() }()
|
||||||
|
|
||||||
|
for variantRows.Next() {
|
||||||
|
var key string
|
||||||
|
|
||||||
|
err := variantRows.Scan(&key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to scan cache_key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.variants.Exists(VariantKey(key)) {
|
||||||
|
t.Errorf("variant_content references key %s but the file is missing", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = variantRows.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant_content iteration failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForUsageAtOrBelow polls UsageBytes until it reaches limit or the
|
||||||
|
// timeout expires, returning the last observed usage.
|
||||||
|
func waitForUsageAtOrBelow(
|
||||||
|
t *testing.T, cache *Cache, limit int64, timeout time.Duration,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
|
||||||
|
var usage int64
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
usage, err = cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage <= limit {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageBytesAccountsSourceAndVariantBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg",
|
||||||
|
bytes.Repeat([]byte{0xAA}, 1000))
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg",
|
||||||
|
bytes.Repeat([]byte{0xAB}, 2000))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0xAC}, 500))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||||
|
bytes.Repeat([]byte{0xAD}, 250))
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 3750 {
|
||||||
|
t.Errorf("UsageBytes = %d, want 3750 (1000+2000+500+250)", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageBytesCountsMultiReferencedBlobOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
content := bytes.Repeat([]byte{0xCC}, 1200)
|
||||||
|
|
||||||
|
hashOne := storeEvictionTestSource(t, cache,
|
||||||
|
"src.example.com", "/one.jpg", content)
|
||||||
|
hashTwo := storeEvictionTestSource(t, cache,
|
||||||
|
"src.example.com", "/two.jpg", content)
|
||||||
|
|
||||||
|
if hashOne != hashTwo {
|
||||||
|
t.Fatalf("identical content produced different hashes: %s vs %s",
|
||||||
|
hashOne, hashTwo)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 1200 {
|
||||||
|
t.Errorf("UsageBytes = %d, want 1200 (deduplicated blob counted once)", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 3000
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
keys := []VariantKey{
|
||||||
|
testVariantKeyOne, testVariantKeyTwo,
|
||||||
|
testVariantKeyThree, testVariantKeyFour,
|
||||||
|
}
|
||||||
|
fills := []byte{0x01, 0x02, 0x03, 0x04}
|
||||||
|
ages := []time.Duration{4 * time.Hour, 3 * time.Hour, 2 * time.Hour, 1 * time.Hour}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
storeEvictionTestVariant(t, cache, key, bytes.Repeat([]byte{fills[i]}, 1000))
|
||||||
|
setVariantLastAccessed(t, cache, key, now.Add(-ages[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cache.EvictToLimit(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.variants.Exists(keys[0]) {
|
||||||
|
t.Errorf("least recently used variant %s must be evicted", keys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(keys[0]),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("evicted variant %s still has %d accounting rows", keys[0], n)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys[1:] {
|
||||||
|
if !cache.variants.Exists(key) {
|
||||||
|
t.Errorf("more recently used variant %s must survive eviction", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 1000
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// One 800-byte blob referenced by two source paths.
|
||||||
|
sharedContent := bytes.Repeat([]byte{0xDD}, 800)
|
||||||
|
sharedHash := storeEvictionTestSource(t, cache,
|
||||||
|
"src.example.com", "/a.jpg", sharedContent)
|
||||||
|
|
||||||
|
h := storeEvictionTestSource(t, cache,
|
||||||
|
"src.example.com", "/b.jpg", sharedContent)
|
||||||
|
if h != sharedHash {
|
||||||
|
t.Fatalf("identical content produced different hashes: %s vs %s",
|
||||||
|
h, sharedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newer 600-byte blob referenced by one source path.
|
||||||
|
recentHash := storeEvictionTestSource(t, cache, "src.example.com", "/c.jpg",
|
||||||
|
bytes.Repeat([]byte{0xEE}, 600))
|
||||||
|
|
||||||
|
setSourceLastAccessed(t, cache, sharedHash, now.Add(-2*time.Hour))
|
||||||
|
setSourceLastAccessed(t, cache, recentHash, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
err := cache.EvictToLimit(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The multi-referenced blob must be gone from disk, from
|
||||||
|
// source_content, and from BOTH source_metadata rows: references
|
||||||
|
// are removed together with the blob, never left dangling.
|
||||||
|
if cache.srcContent.Exists(sharedHash) {
|
||||||
|
t.Errorf("evicted blob %s still exists on disk", sharedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(sharedHash),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("evicted blob %s still has %d source_content rows", sharedHash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(sharedHash),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("evicted blob %s still has %d source_metadata references", sharedHash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The JSON metadata sidecars for both referencing paths must be
|
||||||
|
// removed along with the rows.
|
||||||
|
for _, path := range []string{"/a.jpg", "/b.jpg"} {
|
||||||
|
pathHash := HashPath(path + "?")
|
||||||
|
if cache.srcMetadata.Exists("src.example.com", pathHash) {
|
||||||
|
t.Errorf("metadata sidecar for %s must be removed with its row", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The more recently used blob survives fully intact.
|
||||||
|
if !cache.srcContent.Exists(recentHash) {
|
||||||
|
t.Errorf("recently used blob %s must survive eviction", recentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(recentHash),
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("recently used blob %s has %d source_metadata rows, want 1",
|
||||||
|
recentHash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
content := bytes.Repeat([]byte{0xDF}, 800)
|
||||||
|
hash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content)
|
||||||
|
|
||||||
|
h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content)
|
||||||
|
if h != hash {
|
||||||
|
t.Fatalf("identical content produced different hashes: %s vs %s", h, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0xE0}, 500))
|
||||||
|
|
||||||
|
err := cache.EvictToLimit(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.srcContent.Exists(hash) {
|
||||||
|
t.Errorf("blob %s must not be evicted while usage is under the limit", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(hash),
|
||||||
|
); n != 2 {
|
||||||
|
t.Errorf("blob %s has %d source_metadata rows, want 2", hash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.variants.Exists(testVariantKeyOne) {
|
||||||
|
t.Error("variant must not be evicted while usage is under the limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, tmpDir := newEvictionTestCache(t, 0)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "src.example.com",
|
||||||
|
SourcePath: "/a.jpg",
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDisabledCacheWritesAreNoOps(t, cache, req)
|
||||||
|
assertDisabledCacheReadsAlwaysMiss(t, cache, req)
|
||||||
|
assertDisabledCacheTracksNothing(t, cache)
|
||||||
|
assertDisabledCacheWroteNothingToDisk(t, tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDisabledCacheWritesAreNoOps verifies that stores against a
|
||||||
|
// disabled cache report success without recording anything.
|
||||||
|
func assertDisabledCacheWritesAreNoOps(
|
||||||
|
t *testing.T, cache *Cache, req *ImageRequest,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
ctx, CacheKey(req), bytes.NewReader([]byte("data")), "image/webp",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &httpfetcher.FetchResult{
|
||||||
|
StatusCode: 200,
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
ContentLength: 4,
|
||||||
|
Headers: map[string][]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDisabledCacheReadsAlwaysMiss verifies that lookups against a
|
||||||
|
// disabled cache never report a hit.
|
||||||
|
func assertDisabledCacheReadsAlwaysMiss(
|
||||||
|
t *testing.T, cache *Cache, req *ImageRequest,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
lookup, err := cache.Lookup(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup on disabled cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lookup.Hit {
|
||||||
|
t.Error("Lookup on disabled cache must always miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
srcHash, srcType, err := cache.LookupSource(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcHash != "" || srcType != "" {
|
||||||
|
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
||||||
|
srcHash, srcType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDisabledCacheTracksNothing verifies that a disabled cache
|
||||||
|
// records no usage and writes no accounting rows.
|
||||||
|
func assertDisabledCacheTracksNothing(t *testing.T, cache *Cache) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 0 {
|
||||||
|
t.Errorf("UsageBytes on disabled cache = %d, want 0", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
||||||
|
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_metadata`); n != 0 {
|
||||||
|
t.Errorf("disabled cache wrote %d source_metadata rows, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
||||||
|
// creates neither the cache directory tree nor any file under stateDir.
|
||||||
|
func assertDisabledCacheWroteNothingToDisk(t *testing.T, stateDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := os.Stat(filepath.Join(stateDir, "cache"))
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("disabled cache must not create the cache directory tree "+
|
||||||
|
"(stat err=%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundFiles []string
|
||||||
|
|
||||||
|
walkErr := filepath.WalkDir(stateDir,
|
||||||
|
func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.IsDir() {
|
||||||
|
foundFiles = append(foundFiles, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
t.Fatalf("failed to walk state dir: %v", walkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(foundFiles) != 0 {
|
||||||
|
t.Errorf("disabled cache wrote files to disk: %v", foundFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 1500
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
// An interval far longer than the test ensures only write
|
||||||
|
// pressure can trigger eviction here.
|
||||||
|
cache.StartEviction(time.Hour)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
keys := []VariantKey{
|
||||||
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
|
}
|
||||||
|
fills := []byte{0x11, 0x12, 0x13}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
storeEvictionTestVariant(t, cache, key,
|
||||||
|
bytes.Repeat([]byte{fills[i]}, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("write pressure did not trigger eviction: usage = %d, want <= %d",
|
||||||
|
usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 1500
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
// Start the evictor while the cache is empty, then create tracked
|
||||||
|
// over-limit state WITHOUT going through the store methods, so no
|
||||||
|
// write-pressure notification fires and only the periodic ticker
|
||||||
|
// can trigger eviction.
|
||||||
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
keys := []VariantKey{
|
||||||
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
|
}
|
||||||
|
fills := []byte{0x21, 0x22, 0x23}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
string(key), len(content), "image/webp",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
||||||
|
usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
// An untracked variant file on disk (e.g. written before this
|
||||||
|
// feature existed) must be adopted into the accounting.
|
||||||
|
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(
|
||||||
|
testVariantKeyOne, bytes.NewReader(untracked), "image/webp",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accounting row whose file is missing must be dropped.
|
||||||
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
"deadbeef0001", 700, "image/webp",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert stale variant accounting row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.StartEviction(time.Hour)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
var usage int64
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
usage, err = cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage == 1000 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 1000 {
|
||||||
|
t.Errorf("usage after reconciliation = %d, want 1000 "+
|
||||||
|
"(untracked file adopted, stale row dropped)", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`,
|
||||||
|
string(testVariantKeyOne),
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("untracked variant file was not adopted into accounting (rows=%d)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, "deadbeef0001",
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("stale accounting row without a file was not dropped (rows=%d)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves
|
||||||
|
// reconciliation is not a one-shot startup-only pass: it must also run
|
||||||
|
// on the periodic ticker, so a variant file that lands on disk with no
|
||||||
|
// accounting row well after startup (e.g. because StoreVariant's
|
||||||
|
// best-effort accounting insert failed under transient contention, or
|
||||||
|
// any other cause of an untracked file appearing during steady-state
|
||||||
|
// operation) is still adopted into accounting eventually, rather than
|
||||||
|
// staying invisible to UsageBytes/EvictToLimit until the next process
|
||||||
|
// restart.
|
||||||
|
func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
const interval = 100 * time.Millisecond
|
||||||
|
|
||||||
|
cache.StartEviction(interval)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
// Let startup reconciliation run and settle on an empty cache
|
||||||
|
// before introducing the untracked file, so the adoption we assert
|
||||||
|
// below can only be the work of a later, periodic pass.
|
||||||
|
time.Sleep(3 * interval)
|
||||||
|
|
||||||
|
// Simulate a variant whose accounting insert failed after the
|
||||||
|
// process was already running and serving requests: the content
|
||||||
|
// file is written directly, bypassing StoreVariant's (and thus its
|
||||||
|
// accounting insert) entirely, exactly as would happen if that
|
||||||
|
// insert had failed and only the file write had succeeded.
|
||||||
|
untracked := bytes.Repeat([]byte{0x41}, 900)
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(
|
||||||
|
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
var usage int64
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
usage, err = cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage == 900 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 900 {
|
||||||
|
t.Errorf("usage after periodic reconciliation = %d, want 900 "+
|
||||||
|
"(a file that appeared after startup reconciliation already ran must still "+
|
||||||
|
"be adopted by a later periodic pass)", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, "aabbccdd0099",
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("file that appeared after startup was not adopted by periodic "+
|
||||||
|
"reconciliation (rows=%d)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent exercises
|
||||||
|
// the exact TOCTOU window between evictSourceBlob's row-deletion
|
||||||
|
// transaction commit and its content file unlink: a concurrent
|
||||||
|
// StoreSource for a different source path whose content hashes to the
|
||||||
|
// same value (real SHA-256 dedup, not a contrived case) must not be
|
||||||
|
// able to insert a fresh row referencing the file while eviction is
|
||||||
|
// mid-unlink, and must not lose its own store once eviction has fully
|
||||||
|
// released the content hash.
|
||||||
|
func TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
content := bytes.Repeat([]byte{0x55}, 400)
|
||||||
|
|
||||||
|
hash := storeEvictionTestSource(t, cache,
|
||||||
|
"race.example.com", "/first.jpg", content)
|
||||||
|
|
||||||
|
proceed := make(chan struct{})
|
||||||
|
storeAttempted := make(chan struct{})
|
||||||
|
|
||||||
|
cache.evictSourceBlobTestHook = func(gotHash ContentHash) {
|
||||||
|
if gotHash != hash {
|
||||||
|
t.Errorf("test hook invoked for hash %s, want %s", gotHash, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(storeAttempted)
|
||||||
|
<-proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
evictDone := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
evictDone <- cache.evictSourceBlob(ctx, hash)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until eviction has committed its delete transaction and is
|
||||||
|
// paused (inside the test hook) immediately before unlinking the
|
||||||
|
// content file: exactly the window the review flagged.
|
||||||
|
<-storeAttempted
|
||||||
|
|
||||||
|
storeDone := storeIdenticalContentConcurrently(ctx, cache, content)
|
||||||
|
|
||||||
|
// The concurrent store must not be able to complete while eviction
|
||||||
|
// still holds the content hash (i.e. before the file is unlinked):
|
||||||
|
// if it could, it would insert a row referencing a file about to be
|
||||||
|
// removed out from under it.
|
||||||
|
select {
|
||||||
|
case err := <-storeDone:
|
||||||
|
t.Fatalf("StoreSource for identical content completed (err=%v) while eviction "+
|
||||||
|
"still held the content hash open between commit and unlink; the store and "+
|
||||||
|
"the evict of identical content are not mutually exclusive", err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
// Expected: the store is blocked behind eviction's exclusion.
|
||||||
|
}
|
||||||
|
|
||||||
|
close(proceed)
|
||||||
|
|
||||||
|
err := <-evictDone
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evictSourceBlob failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = <-storeDone
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreSource failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
|
||||||
|
dupHash, _, err := cache.LookupSource(ctx, &ImageRequest{
|
||||||
|
SourceHost: "race.example.com",
|
||||||
|
SourcePath: "/dup.jpg",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupSource failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dupHash == "" {
|
||||||
|
t.Fatal("re-stored blob was lost: the store legitimately ran after eviction " +
|
||||||
|
"released the content hash and must have recreated the file and row")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.srcContent.Exists(dupHash) {
|
||||||
|
t.Errorf("source_content/source_metadata references %s but its file is missing",
|
||||||
|
dupHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeIdenticalContentConcurrently starts a StoreSource for a second
|
||||||
|
// source path whose body hashes to the same content hash, returning the
|
||||||
|
// channel its error is delivered on.
|
||||||
|
func storeIdenticalContentConcurrently(
|
||||||
|
ctx context.Context, cache *Cache, content []byte,
|
||||||
|
) chan error {
|
||||||
|
storeDone := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "race.example.com",
|
||||||
|
SourcePath: "/dup.jpg",
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
result := &httpfetcher.FetchResult{
|
||||||
|
StatusCode: 200,
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
ContentLength: int64(len(content)),
|
||||||
|
Headers: map[string][]string{
|
||||||
|
testHeaderContentType: {testContentTypeJPEG},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cache.StoreSource(ctx, req, bytes.NewReader(content), result)
|
||||||
|
storeDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
return storeDone
|
||||||
|
}
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptrace"
|
|
||||||
neturl "net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetcher configuration constants.
|
|
||||||
const (
|
|
||||||
DefaultFetchTimeout = 30 * time.Second
|
|
||||||
DefaultMaxResponseSize = 50 << 20 // 50MB
|
|
||||||
DefaultTLSTimeout = 10 * time.Second
|
|
||||||
DefaultMaxIdleConns = 100
|
|
||||||
DefaultIdleConnTimeout = 90 * time.Second
|
|
||||||
DefaultMaxRedirects = 10
|
|
||||||
DefaultMaxConnectionsPerHost = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetcher errors.
|
|
||||||
var (
|
|
||||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
|
||||||
ErrInvalidHost = errors.New("invalid or unresolvable host")
|
|
||||||
ErrUnsupportedScheme = errors.New("only HTTPS is supported")
|
|
||||||
ErrResponseTooLarge = errors.New("response exceeds maximum size")
|
|
||||||
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
|
||||||
ErrUpstreamError = errors.New("upstream server error")
|
|
||||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
|
||||||
)
|
|
||||||
|
|
||||||
// FetcherConfig holds configuration for the upstream fetcher.
|
|
||||||
type FetcherConfig struct {
|
|
||||||
// Timeout for upstream requests
|
|
||||||
Timeout time.Duration
|
|
||||||
// MaxResponseSize is the maximum allowed response body size
|
|
||||||
MaxResponseSize int64
|
|
||||||
// UserAgent to send to upstream servers
|
|
||||||
UserAgent string
|
|
||||||
// AllowedContentTypes is a whitelist of MIME types to accept
|
|
||||||
AllowedContentTypes []string
|
|
||||||
// AllowHTTP allows non-TLS connections (for testing only)
|
|
||||||
AllowHTTP bool
|
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host
|
|
||||||
MaxConnectionsPerHost int
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultFetcherConfig returns sensible defaults.
|
|
||||||
func DefaultFetcherConfig() *FetcherConfig {
|
|
||||||
return &FetcherConfig{
|
|
||||||
Timeout: DefaultFetchTimeout,
|
|
||||||
MaxResponseSize: DefaultMaxResponseSize,
|
|
||||||
UserAgent: "pixa/1.0",
|
|
||||||
AllowedContentTypes: []string{
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"image/avif",
|
|
||||||
"image/svg+xml",
|
|
||||||
},
|
|
||||||
AllowHTTP: false,
|
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPFetcher implements the Fetcher interface with SSRF protection.
|
|
||||||
type HTTPFetcher struct {
|
|
||||||
client *http.Client
|
|
||||||
config *FetcherConfig
|
|
||||||
hostSems map[string]chan struct{} // per-host semaphores
|
|
||||||
hostSemMu sync.Mutex // protects hostSems map
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHTTPFetcher creates a new fetcher with SSRF protection.
|
|
||||||
func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
|
|
||||||
if config == nil {
|
|
||||||
config = DefaultFetcherConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create transport with SSRF-safe dialer
|
|
||||||
transport := &http.Transport{
|
|
||||||
DialContext: ssrfSafeDialer,
|
|
||||||
TLSHandshakeTimeout: DefaultTLSTimeout,
|
|
||||||
MaxIdleConns: DefaultMaxIdleConns,
|
|
||||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
Timeout: config.Timeout,
|
|
||||||
// Don't follow redirects automatically - we need to validate each hop
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
if len(via) >= DefaultMaxRedirects {
|
|
||||||
return errors.New("too many redirects")
|
|
||||||
}
|
|
||||||
// Validate the redirect target
|
|
||||||
if err := validateURL(req.URL.String(), config.AllowHTTP); err != nil {
|
|
||||||
return fmt.Errorf("redirect blocked: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return &HTTPFetcher{
|
|
||||||
client: client,
|
|
||||||
config: config,
|
|
||||||
hostSems: make(map[string]chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
|
||||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
|
||||||
f.hostSemMu.Lock()
|
|
||||||
defer f.hostSemMu.Unlock()
|
|
||||||
|
|
||||||
sem, ok := f.hostSems[host]
|
|
||||||
if !ok {
|
|
||||||
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
|
||||||
f.hostSems[host] = sem
|
|
||||||
}
|
|
||||||
|
|
||||||
return sem
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch retrieves content from the given URL with SSRF protection.
|
|
||||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
|
||||||
// Validate URL before making request
|
|
||||||
if err := validateURL(url, f.config.AllowHTTP); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract host for rate limiting
|
|
||||||
host := extractHost(url)
|
|
||||||
|
|
||||||
// Acquire semaphore slot for this host
|
|
||||||
sem := f.getHostSemaphore(host)
|
|
||||||
select {
|
|
||||||
case sem <- struct{}{}:
|
|
||||||
// Acquired slot
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we fail before returning a result, release the slot
|
|
||||||
success := false
|
|
||||||
defer func() {
|
|
||||||
if !success {
|
|
||||||
<-sem
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
parsedURL, err := neturl.Parse(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &http.Request{
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: parsedURL,
|
|
||||||
Header: make(http.Header),
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", f.config.UserAgent)
|
|
||||||
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
|
||||||
|
|
||||||
// Use httptrace to capture connection details
|
|
||||||
var remoteAddr string
|
|
||||||
|
|
||||||
trace := &httptrace.ClientTrace{
|
|
||||||
GotConn: func(info httptrace.GotConnInfo) {
|
|
||||||
if info.Conn != nil {
|
|
||||||
remoteAddr = info.Conn.RemoteAddr().String()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
//nolint:gosec // G704: URL validated by validateURL() above
|
|
||||||
resp, err := f.client.Do(req)
|
|
||||||
|
|
||||||
fetchDuration := time.Since(startTime)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
return nil, ErrUpstreamTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract HTTP version (strip "HTTP/" prefix)
|
|
||||||
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
|
||||||
|
|
||||||
// Extract TLS info if available
|
|
||||||
var tlsVersion, tlsCipherSuite string
|
|
||||||
|
|
||||||
if resp.TLS != nil {
|
|
||||||
tlsVersion = tls.VersionName(resp.TLS.Version)
|
|
||||||
tlsCipherSuite = tls.CipherSuiteName(resp.TLS.CipherSuite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status code
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w: status %d", ErrUpstreamError, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate content type
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
|
||||||
if !f.isAllowedContentType(contentType) {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap body with size limiter and semaphore releaser
|
|
||||||
limitedBody := &limitedReader{
|
|
||||||
reader: resp.Body,
|
|
||||||
remaining: f.config.MaxResponseSize,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark success so defer doesn't release the semaphore
|
|
||||||
success = true
|
|
||||||
|
|
||||||
return &FetchResult{
|
|
||||||
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
|
|
||||||
ContentLength: resp.ContentLength,
|
|
||||||
ContentType: contentType,
|
|
||||||
Headers: resp.Header,
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
FetchDurationMs: fetchDuration.Milliseconds(),
|
|
||||||
RemoteAddr: remoteAddr,
|
|
||||||
HTTPVersion: httpVersion,
|
|
||||||
TLSVersion: tlsVersion,
|
|
||||||
TLSCipherSuite: tlsCipherSuite,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedContentType checks if the content type is in the whitelist.
|
|
||||||
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|
||||||
// Extract the MIME type without parameters
|
|
||||||
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
|
||||||
|
|
||||||
for _, allowed := range f.config.AllowedContentTypes {
|
|
||||||
if strings.EqualFold(mediaType, allowed) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
|
||||||
func validateURL(rawURL string, allowHTTP bool) error {
|
|
||||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
|
||||||
return ErrUnsupportedScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse to extract host
|
|
||||||
host := extractHost(rawURL)
|
|
||||||
if host == "" {
|
|
||||||
return ErrInvalidHost
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove port if present
|
|
||||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block obvious localhost patterns
|
|
||||||
if isLocalhost(host) {
|
|
||||||
return ErrSSRFBlocked
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the host to check IP addresses
|
|
||||||
ips, err := net.LookupIP(host)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ip := range ips {
|
|
||||||
if isPrivateIP(ip) {
|
|
||||||
return ErrSSRFBlocked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractHost extracts the host from a URL string.
|
|
||||||
func extractHost(rawURL string) string {
|
|
||||||
// Simple extraction without full URL parsing
|
|
||||||
url := rawURL
|
|
||||||
if idx := strings.Index(url, "://"); idx != -1 {
|
|
||||||
url = url[idx+3:]
|
|
||||||
}
|
|
||||||
if idx := strings.Index(url, "/"); idx != -1 {
|
|
||||||
url = url[:idx]
|
|
||||||
}
|
|
||||||
if idx := strings.Index(url, "?"); idx != -1 {
|
|
||||||
url = url[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// isLocalhost checks if the host is localhost.
|
|
||||||
func isLocalhost(host string) bool {
|
|
||||||
host = strings.ToLower(host)
|
|
||||||
|
|
||||||
return host == "localhost" ||
|
|
||||||
host == "127.0.0.1" ||
|
|
||||||
host == "::1" ||
|
|
||||||
host == "[::1]" ||
|
|
||||||
strings.HasSuffix(host, ".localhost") ||
|
|
||||||
strings.HasSuffix(host, ".local")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPrivateIP checks if an IP is private, loopback, or otherwise internal.
|
|
||||||
func isPrivateIP(ip net.IP) bool {
|
|
||||||
if ip == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for loopback
|
|
||||||
if ip.IsLoopback() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for private ranges
|
|
||||||
if ip.IsPrivate() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for link-local
|
|
||||||
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for unspecified (0.0.0.0 or ::)
|
|
||||||
if ip.IsUnspecified() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for multicast
|
|
||||||
if ip.IsMulticast() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional checks for IPv4
|
|
||||||
if ip4 := ip.To4(); ip4 != nil {
|
|
||||||
// 169.254.0.0/16 - Link local
|
|
||||||
if ip4[0] == 169 && ip4[1] == 254 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 0.0.0.0/8 - Current network
|
|
||||||
if ip4[0] == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ssrfSafeDialer is a custom dialer that validates IP addresses before connecting.
|
|
||||||
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
host, port, err := net.SplitHostPort(addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the address
|
|
||||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all resolved IPs
|
|
||||||
for _, ip := range ips {
|
|
||||||
if isPrivateIP(ip) {
|
|
||||||
return nil, ErrSSRFBlocked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect using the first valid IP
|
|
||||||
var dialer net.Dialer
|
|
||||||
for _, ip := range ips {
|
|
||||||
addr := net.JoinHostPort(ip.String(), port)
|
|
||||||
conn, err := dialer.DialContext(ctx, network, addr)
|
|
||||||
if err == nil {
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to connect to %s", host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// limitedReader wraps a reader and limits the number of bytes read.
|
|
||||||
type limitedReader struct {
|
|
||||||
reader io.Reader
|
|
||||||
remaining int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *limitedReader) Read(p []byte) (int, error) {
|
|
||||||
if r.remaining <= 0 {
|
|
||||||
return 0, ErrResponseTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
if int64(len(p)) > r.remaining {
|
|
||||||
p = p[:r.remaining]
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := r.reader.Read(p)
|
|
||||||
r.remaining -= int64(n)
|
|
||||||
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// semaphoreReleasingReadCloser releases a semaphore slot when closed.
|
|
||||||
type semaphoreReleasingReadCloser struct {
|
|
||||||
*limitedReader
|
|
||||||
closer io.Closer
|
|
||||||
sem chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *semaphoreReleasingReadCloser) Close() error {
|
|
||||||
err := r.closer.Close()
|
|
||||||
<-r.sem // Release semaphore slot
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -59,6 +59,24 @@ func ValidateFitMode(fit FitMode) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateImageRequest checks a request's width and height with
|
||||||
|
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
|
||||||
|
// /v1/image/ route and the encrypted /v1/e/ route validate through this
|
||||||
|
// function so a request from either source enforces identical bounds.
|
||||||
|
func ValidateImageRequest(req *ImageRequest) error {
|
||||||
|
err := ValidateDimension("width", req.Size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", req.Size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateFitMode(req.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
// ImageRequest represents a request for a processed image
|
// ImageRequest represents a request for a processed image
|
||||||
type ImageRequest struct {
|
type ImageRequest struct {
|
||||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
// SourceHost is the origin host (e.g., "cdn.example.com")
|
||||||
@@ -75,9 +93,10 @@ type ImageRequest struct {
|
|||||||
Quality int
|
Quality int
|
||||||
// FitMode is how to fit the image into requested dimensions
|
// FitMode is how to fit the image into requested dimensions
|
||||||
FitMode FitMode
|
FitMode FitMode
|
||||||
// Signature is the HMAC signature for non-whitelisted 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
|
||||||
@@ -90,6 +109,7 @@ func (r *ImageRequest) SourceURL() string {
|
|||||||
if r.AllowHTTP {
|
if r.AllowHTTP {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
url := scheme + "://" + r.SourceHost + r.SourcePath
|
url := scheme + "://" + r.SourceHost + r.SourcePath
|
||||||
if r.SourceQuery != "" {
|
if r.SourceQuery != "" {
|
||||||
url += "?" + r.SourceQuery
|
url += "?" + r.SourceQuery
|
||||||
@@ -143,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
|
||||||
@@ -163,40 +185,10 @@ type SignatureValidator interface {
|
|||||||
Generate(req *ImageRequest) string
|
Generate(req *ImageRequest) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whitelist checks if a URL is whitelisted (no signature required)
|
// Allowlist checks if a URL is allowlisted (no signature required)
|
||||||
type Whitelist interface {
|
type Allowlist interface {
|
||||||
// IsWhitelisted returns true if the URL doesn't require a signature
|
// IsAllowlisted returns true if the URL doesn't require a signature
|
||||||
IsWhitelisted(u *url.URL) bool
|
IsAllowlisted(u *url.URL) bool
|
||||||
}
|
|
||||||
|
|
||||||
// Fetcher fetches images from upstream origins
|
|
||||||
type Fetcher interface {
|
|
||||||
// Fetch retrieves an image from the origin
|
|
||||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchResult contains the result of fetching from upstream
|
|
||||||
type FetchResult struct {
|
|
||||||
// Content is the raw image data
|
|
||||||
Content io.ReadCloser
|
|
||||||
// ContentLength is the size in bytes (-1 if unknown)
|
|
||||||
ContentLength int64
|
|
||||||
// ContentType is the MIME type from upstream
|
|
||||||
ContentType string
|
|
||||||
// Headers contains all response headers from upstream
|
|
||||||
Headers map[string][]string
|
|
||||||
// StatusCode is the HTTP status code from upstream
|
|
||||||
StatusCode int
|
|
||||||
// FetchDurationMs is how long the fetch took in milliseconds
|
|
||||||
FetchDurationMs int64
|
|
||||||
// RemoteAddr is the IP:port of the upstream server
|
|
||||||
RemoteAddr string
|
|
||||||
// HTTPVersion is the protocol version (e.g., "1.1", "2.0")
|
|
||||||
HTTPVersion string
|
|
||||||
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3")
|
|
||||||
TLSVersion string
|
|
||||||
// TLSCipherSuite is the negotiated cipher suite name
|
|
||||||
TLSCipherSuite string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage handles persistent storage of cached content
|
// Storage handles persistent storage of cached content
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
||||||
|
// keep the photo's aspect ratio.
|
||||||
|
func widthOnlyRequest(fixtures *TestFixtures, width int) *ImageRequest {
|
||||||
|
return &ImageRequest{
|
||||||
|
SourceHost: fixtures.GoodHost,
|
||||||
|
SourcePath: testPathPhoto,
|
||||||
|
Size: Size{Width: width},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdProcessingSlot takes one of proc's processing slots and returns the
|
||||||
|
// func that gives it back. Process takes its slot before it reads its input,
|
||||||
|
// so once it has read a byte from the pipe it holds the slot, until the pipe
|
||||||
|
// is closed.
|
||||||
|
func holdProcessingSlot(
|
||||||
|
t *testing.T, proc *imageprocessor.ImageProcessor,
|
||||||
|
) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
input, feed := io.Pipe()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, _ = proc.Process(t.Context(), input, &imageprocessor.Request{})
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err := feed.Write([]byte{0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process call to hold the slot did not start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release := func() { _ = feed.Close() }
|
||||||
|
t.Cleanup(release)
|
||||||
|
|
||||||
|
return release
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
||||||
|
// request whose source is cached holds none of it while it waits for a
|
||||||
|
// processing slot: it reads the cached file only once it has a slot. With
|
||||||
|
// the only slot held, a request for a new width of the cached 100x100 photo
|
||||||
|
// waits; the cached file is then rewritten as a 100x50 image before the slot
|
||||||
|
// is freed, so the request must answer with that image scaled to 40x20.
|
||||||
|
func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
svc.processor = imageprocessor.New(
|
||||||
|
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
// A first request caches the photo as a source.
|
||||||
|
resp, err := svc.Get(t.Context(), widthOnlyRequest(fixtures, 50))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
||||||
|
widthOnlyRequest(fixtures, 50))
|
||||||
|
if err != nil || contentHash == "" {
|
||||||
|
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
||||||
|
contentHash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release := holdProcessingSlot(t, svc.processor)
|
||||||
|
|
||||||
|
var (
|
||||||
|
waited *ImageResponse
|
||||||
|
waitedErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
waited, waitedErr = svc.Get(t.Context(), widthOnlyRequest(fixtures, 40))
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the request time to reach the slot: had it read the cached source
|
||||||
|
// before waiting, it would have read it by now.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
err = os.WriteFile(svc.cache.srcContent.hashToPath(contentHash),
|
||||||
|
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to rewrite the cached source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if waitedErr != nil {
|
||||||
|
t.Fatalf("Get() error = %v", waitedErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = waited.Content.Close() }()
|
||||||
|
|
||||||
|
output, err := jpeg.DecodeConfig(waited.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode the response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.Width != 40 || output.Height != 20 {
|
||||||
|
t.Errorf("response is %dx%d, want 40x20: the request read the cached "+
|
||||||
|
"source before it had a processing slot", output.Width, output.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webpRequest returns a request for a 100x100 WebP variant of path.
|
||||||
|
func webpRequest(path string) *ImageRequest {
|
||||||
|
return &ImageRequest{
|
||||||
|
SourceHost: testHostCDN,
|
||||||
|
SourcePath: path,
|
||||||
|
Size: Size{Width: 100, Height: 100},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertVariantServed checks that GetVariant serves key with the given
|
||||||
|
// content and the image/webp content type storeEvictionTestVariant stores.
|
||||||
|
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, size, contentType, err := cache.GetVariant(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading variant %s: %v", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, content) {
|
||||||
|
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(content)) {
|
||||||
|
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType != testContentTypeWebP {
|
||||||
|
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
||||||
|
key, contentType, testContentTypeWebP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertVariantNotFound checks that GetVariant refuses key with
|
||||||
|
// ErrNotFound.
|
||||||
|
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, _, _, err := cache.GetVariant(key)
|
||||||
|
if err == nil {
|
||||||
|
_ = reader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertLookupMisses checks that Lookup reports request as a miss.
|
||||||
|
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lookup, err := cache.Lookup(t.Context(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lookup.Hit {
|
||||||
|
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renameFile renames the file at from to to.
|
||||||
|
func renameFile(t *testing.T, from, to string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := os.Rename(from, to)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renaming %s: %v", from, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
||||||
|
// stored or read, a hit takes its content type from memory: with the
|
||||||
|
// .meta file deleted, GetVariant must still return the stored content
|
||||||
|
// type rather than the application/octet-stream it uses without one.
|
||||||
|
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||||
|
content := []byte("webp variant bytes")
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||||
|
|
||||||
|
// A second Cache on the same state directory starts with nothing in
|
||||||
|
// memory, as pixad does after a restart, so its first read uses the
|
||||||
|
// .meta file.
|
||||||
|
restarted, err := NewCache(cache.db, cache.config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
||||||
|
|
||||||
|
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||||
|
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
|
||||||
|
// which began before StoreVariant finished cannot replace the content
|
||||||
|
// type the store kept in memory. Such a read can find the variant file
|
||||||
|
// but not yet its .meta file, and so gets application/octet-stream. The
|
||||||
|
// test deletes the .meta file after the store, then runs the part of
|
||||||
|
// GetVariant that comes after its check of memory.
|
||||||
|
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||||
|
content := []byte("webp variant bytes")
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||||
|
|
||||||
|
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
t.Logf("the read without a .meta file got content type %q", contentType)
|
||||||
|
|
||||||
|
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadOfOlderMetaFileKeepsStoredContentType checks that a read
|
||||||
|
// which got its content type from a .meta file that StoreVariant had
|
||||||
|
// not yet rewritten cannot replace the type the store kept in memory.
|
||||||
|
// The test writes such a .meta file, with a different content type,
|
||||||
|
// after the store, then runs the part of GetVariant that comes after
|
||||||
|
// its check of memory.
|
||||||
|
func TestReadOfOlderMetaFileKeepsStoredContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||||
|
content := []byte("webp variant bytes")
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||||
|
|
||||||
|
olderMeta, err := json.Marshal(VariantMeta{
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
Size: int64(len(content)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoding .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metaPath := cache.variants.keyToPath(testVariantKeyOne) + ".meta"
|
||||||
|
|
||||||
|
err = os.WriteFile(metaPath, olderMeta, StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if contentType != testContentTypeJPEG {
|
||||||
|
t.Fatalf("loadVariantWithMeta(%s) content type = %q, want %q from the .meta file",
|
||||||
|
testVariantKeyOne, contentType, testContentTypeJPEG)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept, _ := cache.metaCache.Get(testVariantKeyOne)
|
||||||
|
if kept != testContentTypeWebP {
|
||||||
|
t.Errorf("content type in memory = %q, want the stored %q",
|
||||||
|
kept, testContentTypeWebP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedReadDuringStoreKeepsStoredContentType checks that a read
|
||||||
|
// which found no .meta file cannot leave application/octet-stream in
|
||||||
|
// memory, even when another read has removed the content type
|
||||||
|
// StoreVariant kept there. In this order: the store; a read that finds
|
||||||
|
// the variant in memory but cannot open its file, and so removes it
|
||||||
|
// from memory; a read that opened the variant file before the store
|
||||||
|
// wrote its .meta file. Later hits must get the stored content type.
|
||||||
|
func TestFailedReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||||
|
content := []byte("webp variant bytes")
|
||||||
|
variantPath := cache.variants.keyToPath(testVariantKeyOne)
|
||||||
|
metaPath := variantPath + ".meta"
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||||
|
|
||||||
|
renameFile(t, variantPath, variantPath+".hidden")
|
||||||
|
assertVariantNotFound(t, cache, testVariantKeyOne)
|
||||||
|
renameFile(t, variantPath+".hidden", variantPath)
|
||||||
|
|
||||||
|
renameFile(t, metaPath, metaPath+".hidden")
|
||||||
|
|
||||||
|
reader, _, contentType, err := cache.GetVariant(testVariantKeyOne)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVariant(%s) error = %v", testVariantKeyOne, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
t.Logf("the read without a .meta file got content type %q", contentType)
|
||||||
|
|
||||||
|
renameFile(t, metaPath+".hidden", metaPath)
|
||||||
|
|
||||||
|
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
||||||
|
// removed is a miss and cannot be read, although it had been stored
|
||||||
|
// and served before.
|
||||||
|
func TestEvictedVariantIsNotServed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1500)
|
||||||
|
|
||||||
|
oldRequest := webpRequest("/old.jpg")
|
||||||
|
newRequest := webpRequest("/new.jpg")
|
||||||
|
oldKey := CacheKey(oldRequest)
|
||||||
|
newKey := CacheKey(newRequest)
|
||||||
|
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
||||||
|
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
||||||
|
storeEvictionTestVariant(t, cache, newKey, newContent)
|
||||||
|
assertVariantServed(t, cache, oldKey, oldContent)
|
||||||
|
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
||||||
|
|
||||||
|
err := cache.EvictToLimit(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvictToLimit() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertLookupMisses(t, cache, oldRequest)
|
||||||
|
assertVariantNotFound(t, cache, oldKey)
|
||||||
|
assertVariantServed(t, cache, newKey, newContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
||||||
|
// file was deleted by something other than the evictor cannot be read,
|
||||||
|
// and is a miss afterwards.
|
||||||
|
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||||
|
|
||||||
|
request := webpRequest("/deleted.jpg")
|
||||||
|
key := CacheKey(request)
|
||||||
|
content := []byte("webp variant bytes")
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, key, content)
|
||||||
|
assertVariantServed(t, cache, key, content)
|
||||||
|
|
||||||
|
err := os.Remove(cache.variants.keyToPath(key))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertVariantNotFound(t, cache, key)
|
||||||
|
assertLookupMisses(t, cache, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
||||||
|
// variants from several goroutines at once, for the race detector.
|
||||||
|
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for goroutine := range 8 {
|
||||||
|
wg.Go(func() {
|
||||||
|
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
||||||
|
content := []byte(key)
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := cache.GetVariant(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetVariant(%s) error = %v", key, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if contentType != testContentTypeWebP {
|
||||||
|
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
||||||
|
key, contentType, testContentTypeWebP)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.evictVariant(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("evictVariant(%s) error = %v", key, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertVariantNotFound(t, cache, key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
+11
-2
@@ -8,6 +8,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "example.com",
|
SourceHost: testHostExample,
|
||||||
SourcePath: "/missing.jpg",
|
SourcePath: "/missing.jpg",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
t.Error("expected no negative cache hit initially")
|
t.Error("expected no negative cache hit initially")
|
||||||
}
|
}
|
||||||
@@ -46,12 +49,15 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hit {
|
if !hit {
|
||||||
t.Error("expected negative cache hit after storing")
|
t.Error("expected negative cache hit after storing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNegativeCache_Expired(t *testing.T) {
|
func TestNegativeCache_Expired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "example.com",
|
SourceHost: testHostExample,
|
||||||
SourcePath: "/expired.jpg",
|
SourcePath: "/expired.jpg",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +90,15 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
t.Error("expected expired negative cache entry to be a miss")
|
t.Error("expected expired negative cache entry to be a miss")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// This test verifies that Service.Get() checks the negative cache
|
// This test verifies that Service.Get() checks the negative cache
|
||||||
// We can't easily test the full pipeline without vips, but we can
|
// We can't easily test the full pipeline without vips, but we can
|
||||||
// verify the error type
|
// verify the error type
|
||||||
+324
-237
@@ -12,16 +12,19 @@ import (
|
|||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"sneak.berlin/go/pixa/internal/allowlist"
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
// Service implements the ImageCache interface, orchestrating cache,
|
||||||
|
// fetcher, and processor.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cache *Cache
|
cache *Cache
|
||||||
fetcher Fetcher
|
fetcher httpfetcher.Fetcher
|
||||||
processor *imageprocessor.ImageProcessor
|
processor *imageprocessor.ImageProcessor
|
||||||
signer *Signer
|
signer *signature.Signer
|
||||||
allowlist *allowlist.HostAllowList
|
allowlist *allowlist.HostAllowList
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
allowHTTP bool
|
allowHTTP bool
|
||||||
@@ -33,42 +36,52 @@ type ServiceConfig struct {
|
|||||||
// Cache is the cache instance
|
// Cache is the cache instance
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
||||||
FetcherConfig *FetcherConfig
|
FetcherConfig *httpfetcher.Config
|
||||||
// Fetcher is an optional custom fetcher (for testing)
|
// Fetcher is an optional custom fetcher (for testing)
|
||||||
Fetcher Fetcher
|
Fetcher httpfetcher.Fetcher
|
||||||
// SigningKey is the HMAC signing key (empty disables signing)
|
// SigningKey is the HMAC signing key (empty disables signing)
|
||||||
SigningKey string
|
SigningKey string
|
||||||
// Whitelist is the list of hosts that don't require signatures
|
// Allowlist is the list of hosts that don't require signatures
|
||||||
Whitelist []string
|
Allowlist []string
|
||||||
|
// MaxConcurrentProcessing is the most images processed at once; zero
|
||||||
|
// uses the image processor's default, one per CPU
|
||||||
|
MaxConcurrentProcessing int
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static errors for service construction and unimplemented operations.
|
||||||
|
var (
|
||||||
|
errCacheRequired = errors.New("cache is required")
|
||||||
|
errSigningKeyRequired = errors.New("signing key is required")
|
||||||
|
errPurgeNotImplemented = errors.New("purge not implemented")
|
||||||
|
)
|
||||||
|
|
||||||
// NewService creates a new image service.
|
// NewService creates a new image service.
|
||||||
func NewService(cfg *ServiceConfig) (*Service, error) {
|
func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||||
if cfg.Cache == nil {
|
if cfg.Cache == nil {
|
||||||
return nil, errors.New("cache is required")
|
return nil, errCacheRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.SigningKey == "" {
|
if cfg.SigningKey == "" {
|
||||||
return nil, errors.New("signing key is required")
|
return nil, errSigningKeyRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fetcher config for defaults
|
// Resolve fetcher config for defaults
|
||||||
fetcherCfg := cfg.FetcherConfig
|
fetcherCfg := cfg.FetcherConfig
|
||||||
if fetcherCfg == nil {
|
if fetcherCfg == nil {
|
||||||
fetcherCfg = DefaultFetcherConfig()
|
fetcherCfg = httpfetcher.DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
||||||
var fetcher Fetcher
|
var fetcher httpfetcher.Fetcher
|
||||||
if cfg.Fetcher != nil {
|
if cfg.Fetcher != nil {
|
||||||
fetcher = cfg.Fetcher
|
fetcher = cfg.Fetcher
|
||||||
} else {
|
} else {
|
||||||
fetcher = NewHTTPFetcher(fetcherCfg)
|
fetcher = httpfetcher.New(fetcherCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
signer := NewSigner(cfg.SigningKey)
|
signer := signature.New(cfg.SigningKey)
|
||||||
|
|
||||||
log := cfg.Logger
|
log := cfg.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
@@ -81,13 +94,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxResponseSize := fetcherCfg.MaxResponseSize
|
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||||
|
processor := imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: maxResponseSize,
|
||||||
|
MaxConcurrentProcessing: cfg.MaxConcurrentProcessing,
|
||||||
|
})
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
fetcher: fetcher,
|
fetcher: fetcher,
|
||||||
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
|
processor: processor,
|
||||||
signer: signer,
|
signer: signer,
|
||||||
allowlist: allowlist.New(cfg.Whitelist),
|
allowlist: allowlist.New(cfg.Allowlist),
|
||||||
log: log,
|
log: log,
|
||||||
allowHTTP: allowHTTP,
|
allowHTTP: allowHTTP,
|
||||||
maxResponseSize: maxResponseSize,
|
maxResponseSize: maxResponseSize,
|
||||||
@@ -107,13 +124,14 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("negative cache check failed", "error", err)
|
s.log.Warn("negative cache check failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if negHit {
|
if negHit {
|
||||||
s.log.Debug("negative cache hit",
|
s.log.Debug("negative cache hit",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached)
|
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check variant cache first (disk only, no DB)
|
// Check variant cache first (disk only, no DB)
|
||||||
@@ -129,7 +147,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,
|
||||||
@@ -141,11 +160,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, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
||||||
|
|
||||||
|
s.cache.IncrementStats(context.WithoutCancel(ctx), false, fetchedBytes)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -155,214 +178,6 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCachedSource attempts to load source content from cache, returning nil
|
|
||||||
// if the cached data is unavailable or exceeds maxResponseSize.
|
|
||||||
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
|
||||||
reader, err := s.cache.GetSourceContent(contentHash)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to load cached source, fetching", "error", err)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bound the read to maxResponseSize to prevent unbounded memory use
|
|
||||||
// from unexpectedly large cached files.
|
|
||||||
limited := io.LimitReader(reader, s.maxResponseSize+1)
|
|
||||||
data, err := io.ReadAll(limited)
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to read cached source, fetching", "error", err)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if int64(len(data)) > s.maxResponseSize {
|
|
||||||
s.log.Warn("cached source exceeds max response size, discarding",
|
|
||||||
"hash", contentHash,
|
|
||||||
"max_bytes", s.maxResponseSize,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content if available.
|
|
||||||
func (s *Service) processFromSourceOrFetch(
|
|
||||||
ctx context.Context,
|
|
||||||
req *ImageRequest,
|
|
||||||
cacheKey VariantKey,
|
|
||||||
) (*ImageResponse, error) {
|
|
||||||
// Check if we have cached source content
|
|
||||||
contentHash, _, err := s.cache.LookupSource(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("source lookup failed", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceData []byte
|
|
||||||
var fetchBytes int64
|
|
||||||
|
|
||||||
if contentHash != "" {
|
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
|
||||||
sourceData = s.loadCachedSource(contentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch from upstream if we don't have source data or it's empty
|
|
||||||
if len(sourceData) == 0 {
|
|
||||||
resp, err := s.fetchAndProcess(ctx, req, cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process using cached source
|
|
||||||
fetchBytes = int64(len(sourceData))
|
|
||||||
|
|
||||||
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
|
||||||
func (s *Service) fetchAndProcess(
|
|
||||||
ctx context.Context,
|
|
||||||
req *ImageRequest,
|
|
||||||
cacheKey VariantKey,
|
|
||||||
) (*ImageResponse, error) {
|
|
||||||
// Fetch from upstream
|
|
||||||
sourceURL := req.SourceURL()
|
|
||||||
|
|
||||||
s.log.Debug("fetching from upstream", "url", sourceURL)
|
|
||||||
|
|
||||||
fetchResult, err := s.fetcher.Fetch(ctx, sourceURL)
|
|
||||||
if err != nil {
|
|
||||||
// Store negative cache for certain errors
|
|
||||||
if isNegativeCacheable(err) {
|
|
||||||
statusCode := extractStatusCode(err)
|
|
||||||
_ = s.cache.StoreNegative(ctx, req, statusCode, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("upstream fetch failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = fetchResult.Content.Close() }()
|
|
||||||
|
|
||||||
// Read and validate the source content
|
|
||||||
sourceData, err := io.ReadAll(fetchResult.Content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate download bitrate
|
|
||||||
fetchBytes := int64(len(sourceData))
|
|
||||||
var downloadRate string
|
|
||||||
|
|
||||||
if fetchResult.FetchDurationMs > 0 {
|
|
||||||
seconds := float64(fetchResult.FetchDurationMs) / 1000.0 //nolint:mnd // ms to seconds
|
|
||||||
bitsPerSecond := float64(fetchBytes*8) / seconds //nolint:mnd // bytes to bits
|
|
||||||
downloadRate = humanize.SI(bitsPerSecond, "bps")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log upstream fetch details
|
|
||||||
s.log.Info("upstream fetched",
|
|
||||||
"host", req.SourceHost,
|
|
||||||
"path", req.SourcePath,
|
|
||||||
"bytes", fetchBytes,
|
|
||||||
"fetch_ms", fetchResult.FetchDurationMs,
|
|
||||||
"rate", downloadRate,
|
|
||||||
"remote_addr", fetchResult.RemoteAddr,
|
|
||||||
"http", fetchResult.HTTPVersion,
|
|
||||||
"tls", fetchResult.TLSVersion,
|
|
||||||
"cipher", fetchResult.TLSCipherSuite,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validate magic bytes match content type
|
|
||||||
if err := magic.ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
|
|
||||||
return nil, fmt.Errorf("content validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store source content
|
|
||||||
_, err = s.cache.StoreSource(ctx, req, bytes.NewReader(sourceData), fetchResult)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to store source content", "error", err)
|
|
||||||
// Continue even if caching fails
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// processAndStore processes an image and stores the result.
|
|
||||||
func (s *Service) processAndStore(
|
|
||||||
ctx context.Context,
|
|
||||||
req *ImageRequest,
|
|
||||||
cacheKey VariantKey,
|
|
||||||
sourceData []byte,
|
|
||||||
fetchBytes int64,
|
|
||||||
) (*ImageResponse, error) {
|
|
||||||
// Process the image
|
|
||||||
processStart := time.Now()
|
|
||||||
|
|
||||||
processReq := &imageprocessor.Request{
|
|
||||||
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
|
|
||||||
Format: imageprocessor.Format(req.Format),
|
|
||||||
Quality: req.Quality,
|
|
||||||
FitMode: imageprocessor.FitMode(req.FitMode),
|
|
||||||
}
|
|
||||||
|
|
||||||
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
processDuration := time.Since(processStart)
|
|
||||||
|
|
||||||
// Read processed content
|
|
||||||
processedData, err := io.ReadAll(processResult.Content)
|
|
||||||
_ = processResult.Content.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read processed content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log conversion details
|
|
||||||
outputSize := int64(len(processedData))
|
|
||||||
|
|
||||||
var sizePercent float64
|
|
||||||
if fetchBytes > 0 {
|
|
||||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0 //nolint:mnd // percentage calculation
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("image converted",
|
|
||||||
"host", req.SourceHost,
|
|
||||||
"path", req.SourcePath,
|
|
||||||
"src_format", processResult.InputFormat,
|
|
||||||
"dst_format", req.Format,
|
|
||||||
"src_bytes", fetchBytes,
|
|
||||||
"dst_bytes", outputSize,
|
|
||||||
"src_dimensions", fmt.Sprintf("%dx%d", processResult.InputWidth, processResult.InputHeight),
|
|
||||||
"dst_dimensions", fmt.Sprintf("%dx%d", processResult.Width, processResult.Height),
|
|
||||||
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
|
||||||
"convert_ms", processDuration.Milliseconds(),
|
|
||||||
"quality", req.Quality,
|
|
||||||
"fit", req.FitMode,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store variant to cache
|
|
||||||
if err := s.cache.StoreVariant(cacheKey, bytes.NewReader(processedData), processResult.ContentType); err != nil {
|
|
||||||
s.log.Warn("failed to store variant", "error", err)
|
|
||||||
// Continue even if caching fails
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ImageResponse{
|
|
||||||
Content: io.NopCloser(bytes.NewReader(processedData)),
|
|
||||||
ContentLength: outputSize,
|
|
||||||
ContentType: processResult.ContentType,
|
|
||||||
FetchedBytes: fetchBytes,
|
|
||||||
ETag: formatETag(cacheKey),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warm pre-fetches and caches an image without returning it.
|
// Warm pre-fetches and caches an image without returning it.
|
||||||
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
||||||
_, err := s.Get(ctx, req)
|
_, err := s.Get(ctx, req)
|
||||||
@@ -370,10 +185,9 @@ func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge removes a cached image.
|
// Purge removes a cached image. Purging is not implemented yet.
|
||||||
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
||||||
// TODO: Implement purge
|
return errPurgeNotImplemented
|
||||||
return errors.New("purge not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats returns cache statistics.
|
// Stats returns cache statistics.
|
||||||
@@ -396,18 +210,291 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Signature required for non-allowed hosts
|
// Signature required for non-allowed hosts
|
||||||
return s.signer.Verify(req)
|
return s.signer.Verify(signatureRequest(req))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSignedURL generates a signed URL for the given request.
|
// GenerateSignedURL generates a signed URL for the given request. The URL
|
||||||
|
// carries q and fit next to sig and exp, so the image route verifies it for
|
||||||
|
// the quality and fit it was signed with. An unset quality or fit is first
|
||||||
|
// set to 85 or cover, the values the route uses when a URL has no q or fit.
|
||||||
func (s *Service) GenerateSignedURL(
|
func (s *Service) GenerateSignedURL(
|
||||||
baseURL string,
|
baseURL string,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
ttl time.Duration,
|
ttl time.Duration,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
|
if req.Quality == 0 {
|
||||||
|
req.Quality = 85
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
if req.FitMode == "" {
|
||||||
|
req.FitMode = FitCover
|
||||||
|
}
|
||||||
|
|
||||||
|
sigReq := signatureRequest(req)
|
||||||
|
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||||
|
|
||||||
|
// Propagate the generated signature and expiration back onto the request.
|
||||||
|
req.Expires = sigReq.Expires
|
||||||
|
req.Signature = sigReq.Signature
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s",
|
||||||
|
baseURL, path, sig, exp, req.Quality, req.FitMode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCachedSource opens source content from cache, without reading it, and
|
||||||
|
// returns it with its size; nil if the cached data is unavailable, empty or
|
||||||
|
// exceeds maxResponseSize.
|
||||||
|
func (s *Service) loadCachedSource(
|
||||||
|
contentHash ContentHash,
|
||||||
|
) (io.ReadCloser, int64) {
|
||||||
|
reader, size, err := s.cache.GetSourceContent(contentHash)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if size > s.maxResponseSize {
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
s.log.Warn("cached source exceeds max response size, discarding",
|
||||||
|
"hash", contentHash,
|
||||||
|
"max_bytes", s.maxResponseSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if size == 0 {
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader, size
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFromSourceOrFetch processes an image, using cached source content
|
||||||
|
// 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(
|
||||||
|
ctx context.Context,
|
||||||
|
req *ImageRequest,
|
||||||
|
cacheKey VariantKey,
|
||||||
|
) (*ImageResponse, int64, error) {
|
||||||
|
// Check if we have cached source content
|
||||||
|
contentHash, _, err := s.cache.LookupSource(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("source lookup failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
source io.ReadCloser
|
||||||
|
sourceSize int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if contentHash != "" {
|
||||||
|
s.log.Debug("using cached source", "hash", contentHash)
|
||||||
|
source, sourceSize = s.loadCachedSource(contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
|
if source == nil {
|
||||||
|
return s.fetchAndProcess(ctx, req, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = source.Close() }()
|
||||||
|
|
||||||
|
// Process using cached source; nothing was fetched from upstream. The
|
||||||
|
// image processor reads the source only once it has a processing slot,
|
||||||
|
// so a request waiting for one holds none of it in memory.
|
||||||
|
resp, err := s.processAndStore(ctx, req, cacheKey, source, sourceSize)
|
||||||
|
|
||||||
|
return resp, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||||
|
// It also returns the number of bytes read from upstream, including when
|
||||||
|
// reading the response or a later step fails.
|
||||||
|
func (s *Service) fetchAndProcess(
|
||||||
|
ctx context.Context,
|
||||||
|
req *ImageRequest,
|
||||||
|
cacheKey VariantKey,
|
||||||
|
) (*ImageResponse, int64, error) {
|
||||||
|
// Fetch from upstream
|
||||||
|
sourceURL := req.SourceURL()
|
||||||
|
|
||||||
|
s.log.Debug("fetching from upstream", "url", sourceURL)
|
||||||
|
|
||||||
|
fetchResult, err := s.fetcher.Fetch(ctx, sourceURL)
|
||||||
|
if err != nil {
|
||||||
|
// Store negative cache for certain errors
|
||||||
|
if isNegativeCacheable(err) {
|
||||||
|
statusCode := extractStatusCode(err)
|
||||||
|
|
||||||
|
storeErr := s.cache.StoreNegative(ctx, req, statusCode, err.Error())
|
||||||
|
if storeErr != nil {
|
||||||
|
s.log.Warn("failed to store negative cache entry",
|
||||||
|
"host", req.SourceHost, "path", req.SourcePath, "error", storeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, 0, fmt.Errorf("upstream fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing the body frees the upstream connection. It is closed only
|
||||||
|
// after processing, so the fetcher's connection limit also bounds the
|
||||||
|
// fetched sources held in memory while their requests wait for a
|
||||||
|
// processing slot.
|
||||||
|
defer func() { _ = fetchResult.Content.Close() }()
|
||||||
|
|
||||||
|
// Read and validate the source content
|
||||||
|
sourceData, err := io.ReadAll(fetchResult.Content)
|
||||||
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fetchBytes, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate download bitrate
|
||||||
|
var downloadRate string
|
||||||
|
|
||||||
|
if fetchResult.FetchDurationMs > 0 {
|
||||||
|
seconds := float64(fetchResult.FetchDurationMs) / 1000.0 //nolint:mnd // ms to seconds
|
||||||
|
bitsPerSecond := float64(fetchBytes*8) / seconds //nolint:mnd // bytes to bits
|
||||||
|
downloadRate = humanize.SI(bitsPerSecond, "bps")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log upstream fetch details
|
||||||
|
s.log.Info("upstream fetched",
|
||||||
|
"host", req.SourceHost,
|
||||||
|
"path", req.SourcePath,
|
||||||
|
"bytes", fetchBytes,
|
||||||
|
"fetch_ms", fetchResult.FetchDurationMs,
|
||||||
|
"rate", downloadRate,
|
||||||
|
"remote_addr", fetchResult.RemoteAddr,
|
||||||
|
"http", fetchResult.HTTPVersion,
|
||||||
|
"tls", fetchResult.TLSVersion,
|
||||||
|
"cipher", fetchResult.TLSCipherSuite,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate magic bytes match content type
|
||||||
|
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fetchBytes, fmt.Errorf("content validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store source content
|
||||||
|
_, err = s.cache.StoreSource(ctx, req, bytes.NewReader(sourceData), fetchResult)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to store source content", "error", err)
|
||||||
|
// Continue even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.processAndStore(
|
||||||
|
ctx, req, cacheKey, bytes.NewReader(sourceData), fetchBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp, fetchBytes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAndStore processes the image read from source and stores the
|
||||||
|
// result.
|
||||||
|
func (s *Service) processAndStore(
|
||||||
|
ctx context.Context,
|
||||||
|
req *ImageRequest,
|
||||||
|
cacheKey VariantKey,
|
||||||
|
source io.Reader,
|
||||||
|
fetchBytes int64,
|
||||||
|
) (*ImageResponse, error) {
|
||||||
|
// Process the image
|
||||||
|
processStart := time.Now()
|
||||||
|
|
||||||
|
processReq := &imageprocessor.Request{
|
||||||
|
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
|
||||||
|
Format: imageprocessor.Format(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: imageprocessor.FitMode(req.FitMode),
|
||||||
|
}
|
||||||
|
|
||||||
|
processResult, err := s.processor.Process(ctx, source, processReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
|
// Counted also when the request context has ended meanwhile
|
||||||
|
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
|
||||||
|
|
||||||
|
// Read processed content
|
||||||
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
|
_ = processResult.Content.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read processed content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log conversion details
|
||||||
|
outputSize := int64(len(processedData))
|
||||||
|
|
||||||
|
var sizePercent float64
|
||||||
|
if fetchBytes > 0 {
|
||||||
|
//nolint:mnd // percentage calculation
|
||||||
|
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("image converted",
|
||||||
|
"host", req.SourceHost,
|
||||||
|
"path", req.SourcePath,
|
||||||
|
"src_format", processResult.InputFormat,
|
||||||
|
"dst_format", req.Format,
|
||||||
|
"src_bytes", fetchBytes,
|
||||||
|
"dst_bytes", outputSize,
|
||||||
|
"src_dimensions", fmt.Sprintf("%dx%d",
|
||||||
|
processResult.InputWidth, processResult.InputHeight),
|
||||||
|
"dst_dimensions", fmt.Sprintf("%dx%d",
|
||||||
|
processResult.Width, processResult.Height),
|
||||||
|
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
||||||
|
"convert_ms", processDuration.Milliseconds(),
|
||||||
|
"quality", req.Quality,
|
||||||
|
"fit", req.FitMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store variant to cache
|
||||||
|
err = s.cache.StoreVariant(
|
||||||
|
ctx, cacheKey, bytes.NewReader(processedData), processResult.ContentType,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to store variant", "error", err)
|
||||||
|
// Continue even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImageResponse{
|
||||||
|
Content: io.NopCloser(bytes.NewReader(processedData)),
|
||||||
|
ContentLength: outputSize,
|
||||||
|
ContentType: processResult.ContentType,
|
||||||
|
FetchedBytes: fetchBytes,
|
||||||
|
ETag: formatETag(cacheKey),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signatureRequest projects an ImageRequest onto the standalone
|
||||||
|
// signature.Request type used by the signature package. This keeps the
|
||||||
|
// import edge one-way: imgcache depends on signature, never the reverse.
|
||||||
|
func signatureRequest(req *ImageRequest) *signature.Request {
|
||||||
|
return &signature.Request{
|
||||||
|
SourceHost: req.SourceHost,
|
||||||
|
SourcePath: req.SourcePath,
|
||||||
|
SourceQuery: req.SourceQuery,
|
||||||
|
Width: req.Size.Width,
|
||||||
|
Height: req.Size.Height,
|
||||||
|
Format: string(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: string(req.FitMode),
|
||||||
|
Signature: req.Signature,
|
||||||
|
Expires: req.Expires,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP status codes for error responses.
|
// HTTP status codes for error responses.
|
||||||
@@ -418,13 +505,13 @@ const (
|
|||||||
|
|
||||||
// isNegativeCacheable returns true if the error should be cached.
|
// isNegativeCacheable returns true if the error should be cached.
|
||||||
func isNegativeCacheable(err error) bool {
|
func isNegativeCacheable(err error) bool {
|
||||||
return errors.Is(err, ErrUpstreamError)
|
return errors.Is(err, httpfetcher.ErrUpstreamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractStatusCode extracts HTTP status code from error message.
|
// extractStatusCode extracts HTTP status code from error message.
|
||||||
func extractStatusCode(err error) int {
|
func extractStatusCode(err error) int {
|
||||||
// Default to 502 Bad Gateway for upstream errors
|
// Default to 502 Bad Gateway for upstream errors
|
||||||
if errors.Is(err, ErrUpstreamError) {
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
return httpStatusBadGateway
|
return httpStatusBadGateway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestService_Get_WhitelistedHost(t *testing.T) {
|
// Test data literals used repeatedly in this file (goconst).
|
||||||
|
const (
|
||||||
|
testPathPhoto = "/images/photo.jpg"
|
||||||
|
testPathUpload = "/uploads/image.jpg"
|
||||||
|
testSigningKey = "test-signing-key-12345"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService_Get_AllowlistedHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -26,7 +36,8 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// Verify we got content
|
// Verify we got content
|
||||||
data, err := io.ReadAll(resp.Content)
|
data, err := io.ReadAll(resp.Content)
|
||||||
@@ -38,38 +49,42 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
|
|||||||
t.Error("expected non-empty response")
|
t.Error("expected non-empty response")
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.ContentType != "image/jpeg" {
|
if resp.ContentType != testContentTypeJPEG {
|
||||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg")
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should fail validation - not whitelisted and no signature
|
// Should fail validation - not allowlisted and no signature
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateRequest() expected error for non-whitelisted host without signature")
|
t.Error("ValidateRequest() expected error for non-allowlisted host without signature")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := testSigningKey
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -77,9 +92,9 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate a valid signature
|
// Generate a valid signature
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
req.Expires = time.Now().Add(time.Hour)
|
req.Expires = time.Now().Add(time.Hour)
|
||||||
req.Signature = signer.Sign(req)
|
req.Signature = signer.Sign(signatureRequest(req))
|
||||||
|
|
||||||
// Should pass validation
|
// Should pass validation
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
@@ -92,7 +107,8 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Content)
|
data, err := io.ReadAll(resp.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,13 +120,15 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := testSigningKey
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -118,9 +136,9 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate an expired signature
|
// Generate an expired signature
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
||||||
req.Signature = signer.Sign(req)
|
req.Signature = signer.Sign(signatureRequest(req))
|
||||||
|
|
||||||
// Should fail validation
|
// Should fail validation
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
@@ -129,13 +147,15 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := testSigningKey
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -158,28 +178,32 @@ func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
|
|||||||
// signature for one host must not verify for a different host, even
|
// signature for one host must not verify for a different host, even
|
||||||
// if they share a domain suffix.
|
// if they share a domain suffix.
|
||||||
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
signingKey := "test-signing-key-must-be-32-chars"
|
signingKey := "test-signing-key-must-be-32-chars"
|
||||||
svc, _ := SetupTestService(t,
|
svc, _ := SetupTestService(t,
|
||||||
WithSigningKey(signingKey),
|
WithSigningKey(signingKey),
|
||||||
WithNoWhitelist(),
|
WithNoAllowlist(),
|
||||||
)
|
)
|
||||||
|
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
|
|
||||||
// Sign a request for "cdn.example.com"
|
// Sign a request for "cdn.example.com"
|
||||||
signedReq := &ImageRequest{
|
signedReq := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
Expires: time.Now().Add(time.Hour),
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
signedReq.Signature = signer.Sign(signedReq)
|
signedReq.Signature = signer.Sign(signatureRequest(signedReq))
|
||||||
|
|
||||||
// The original request should pass validation
|
// The original request should pass validation
|
||||||
t.Run("exact host passes", func(t *testing.T) {
|
t.Run("exact host passes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
err := svc.ValidateRequest(signedReq)
|
err := svc.ValidateRequest(signedReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ValidateRequest() exact host failed: %v", err)
|
t.Errorf("ValidateRequest() exact host failed: %v", err)
|
||||||
@@ -191,7 +215,7 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
host string
|
host string
|
||||||
}{
|
}{
|
||||||
{"parent domain", "example.com"},
|
{"parent domain", testHostExample},
|
||||||
{"sibling subdomain", "images.example.com"},
|
{"sibling subdomain", "images.example.com"},
|
||||||
{"deeper subdomain", "a.cdn.example.com"},
|
{"deeper subdomain", "a.cdn.example.com"},
|
||||||
{"evil suffix domain", "cdn.example.com.evil.com"},
|
{"evil suffix domain", "cdn.example.com.evil.com"},
|
||||||
@@ -200,6 +224,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name+" rejected", func(t *testing.T) {
|
t.Run(tt.name+" rejected", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: tt.host,
|
SourceHost: tt.host,
|
||||||
SourcePath: signedReq.SourcePath,
|
SourcePath: signedReq.SourcePath,
|
||||||
@@ -214,7 +240,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
|||||||
|
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("ValidateRequest() should reject signature for host %q (signed for %q)",
|
t.Errorf(
|
||||||
|
"ValidateRequest() should reject signature for host %q (signed for %q)",
|
||||||
tt.host, signedReq.SourceHost)
|
tt.host, signedReq.SourceHost)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -222,6 +249,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_InvalidFile(t *testing.T) {
|
func TestService_Get_InvalidFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -242,6 +271,8 @@ func TestService_Get_InvalidFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NotFound(t *testing.T) {
|
func TestService_Get_NotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -261,6 +292,8 @@ func TestService_Get_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_FormatConversion(t *testing.T) {
|
func TestService_Get_FormatConversion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -272,7 +305,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "JPEG to PNG",
|
name: "JPEG to PNG",
|
||||||
sourcePath: "/images/photo.jpg",
|
sourcePath: testPathPhoto,
|
||||||
outFormat: FormatPNG,
|
outFormat: FormatPNG,
|
||||||
wantMIME: "image/png",
|
wantMIME: "image/png",
|
||||||
},
|
},
|
||||||
@@ -280,7 +313,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
name: "PNG to JPEG",
|
name: "PNG to JPEG",
|
||||||
sourcePath: "/images/logo.png",
|
sourcePath: "/images/logo.png",
|
||||||
outFormat: FormatJPEG,
|
outFormat: FormatJPEG,
|
||||||
wantMIME: "image/jpeg",
|
wantMIME: testContentTypeJPEG,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GIF to PNG",
|
name: "GIF to PNG",
|
||||||
@@ -292,6 +325,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: tt.sourcePath,
|
SourcePath: tt.sourcePath,
|
||||||
@@ -305,7 +340,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
if resp.ContentType != tt.wantMIME {
|
if resp.ContentType != tt.wantMIME {
|
||||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
||||||
@@ -340,12 +376,14 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_Caching(t *testing.T) {
|
func TestService_Get_Caching(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -366,7 +404,8 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read first response: %v", err)
|
t.Fatalf("failed to read first response: %v", err)
|
||||||
}
|
}
|
||||||
resp1.Content.Close()
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
// Second request - should be a cache hit
|
// Second request - should be a cache hit
|
||||||
resp2, err := svc.Get(ctx, req)
|
resp2, err := svc.Get(ctx, req)
|
||||||
@@ -382,7 +421,8 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read second response: %v", err)
|
t.Fatalf("failed to read second response: %v", err)
|
||||||
}
|
}
|
||||||
resp2.Content.Close()
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
// Content should be identical
|
// Content should be identical
|
||||||
if len(data1) != len(data2) {
|
if len(data1) != len(data2) {
|
||||||
@@ -391,6 +431,8 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_DifferentSizes(t *testing.T) {
|
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -401,12 +443,12 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
|||||||
{Width: 75, Height: 75},
|
{Width: 75, Height: 75},
|
||||||
}
|
}
|
||||||
|
|
||||||
var responses [][]byte
|
responses := make([][]byte, 0, len(sizes))
|
||||||
|
|
||||||
for _, size := range sizes {
|
for _, size := range sizes {
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: size,
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -422,27 +464,31 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read response: %v", err)
|
t.Fatalf("failed to read response: %v", err)
|
||||||
}
|
}
|
||||||
resp.Content.Close()
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
responses = append(responses, data)
|
responses = append(responses, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All responses should be different sizes (different cache entries)
|
// All responses should be different sizes (different cache entries)
|
||||||
for i := 0; i < len(responses)-1; i++ {
|
for i := range len(responses) - 1 {
|
||||||
if len(responses[i]) == len(responses[i+1]) {
|
if len(responses[i]) == len(responses[i+1]) {
|
||||||
// Not necessarily an error, but worth noting
|
// Not necessarily an error, but worth noting
|
||||||
t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i]))
|
t.Logf("responses %d and %d have same size: %d bytes",
|
||||||
|
i, i+1, len(responses[i]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
||||||
// Service with no signing key - all non-whitelisted requests should fail
|
t.Parallel()
|
||||||
svc, fixtures := SetupTestService(t, WithNoWhitelist())
|
|
||||||
|
// Service with no signing key - all non-allowlisted requests should fail
|
||||||
|
svc, fixtures := SetupTestService(t, WithNoAllowlist())
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -451,11 +497,15 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
|||||||
|
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted")
|
t.Error(
|
||||||
|
"ValidateRequest() expected error when no signing key and host not allowlisted",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ContextCancellation(t *testing.T) {
|
func TestService_Get_ContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -463,7 +513,7 @@ func TestService_Get_ContextCancellation(t *testing.T) {
|
|||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -477,12 +527,14 @@ func TestService_Get_ContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ReturnsETag(t *testing.T) {
|
func TestService_Get_ReturnsETag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -493,7 +545,8 @@ func TestService_Get_ReturnsETag(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// ETag should be set
|
// ETag should be set
|
||||||
if resp.ETag == "" {
|
if resp.ETag == "" {
|
||||||
@@ -507,12 +560,14 @@ func TestService_Get_ReturnsETag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ETagConsistency(t *testing.T) {
|
func TestService_Get_ETagConsistency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -524,16 +579,20 @@ func TestService_Get_ETagConsistency(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() first request error = %v", err)
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag1 := resp1.ETag
|
etag1 := resp1.ETag
|
||||||
resp1.Content.Close()
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
// Second request (from cache)
|
// Second request (from cache)
|
||||||
resp2, err := svc.Get(ctx, req)
|
resp2, err := svc.Get(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() second request error = %v", err)
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag2 := resp2.ETag
|
etag2 := resp2.ETag
|
||||||
resp2.Content.Close()
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
// ETags should be identical for the same content
|
// ETags should be identical for the same content
|
||||||
if etag1 != etag2 {
|
if etag1 != etag2 {
|
||||||
@@ -542,13 +601,15 @@ func TestService_Get_ETagConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Request same image at different sizes - should get different ETags
|
// Request same image at different sizes - should get different ETags
|
||||||
req1 := &ImageRequest{
|
req1 := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 25, Height: 25},
|
Size: Size{Width: 25, Height: 25},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -557,7 +618,7 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
|||||||
|
|
||||||
req2 := &ImageRequest{
|
req2 := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -568,15 +629,19 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() first request error = %v", err)
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag1 := resp1.ETag
|
etag1 := resp1.ETag
|
||||||
resp1.Content.Close()
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
resp2, err := svc.Get(ctx, req2)
|
resp2, err := svc.Get(ctx, req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() second request error = %v", err)
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag2 := resp2.ETag
|
etag2 := resp2.ETag
|
||||||
resp2.Content.Close()
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
// ETags should be different for different content
|
// ETags should be different for different content
|
||||||
if etag1 == etag2 {
|
if etag1 == etag2 {
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signature errors.
|
|
||||||
var (
|
|
||||||
ErrSignatureRequired = errors.New("signature required for non-whitelisted host")
|
|
||||||
ErrSignatureInvalid = errors.New("invalid signature")
|
|
||||||
ErrSignatureExpired = errors.New("signature has expired")
|
|
||||||
ErrMissingExpiration = errors.New("signature expiration is required")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signer handles HMAC-SHA256 signature generation and verification.
|
|
||||||
type Signer struct {
|
|
||||||
secretKey []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSigner creates a new Signer with the given secret key.
|
|
||||||
func NewSigner(secretKey string) *Signer {
|
|
||||||
return &Signer{
|
|
||||||
secretKey: []byte(secretKey),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given image request.
|
|
||||||
// The signature covers: host + path + query + width + height + format + expiration.
|
|
||||||
func (s *Signer) Sign(req *ImageRequest) string {
|
|
||||||
data := s.buildSignatureData(req)
|
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
|
||||||
mac.Write([]byte(data))
|
|
||||||
sig := mac.Sum(nil)
|
|
||||||
|
|
||||||
return base64.URLEncoding.EncodeToString(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checks if the signature on the request is valid and not expired.
|
|
||||||
// Signatures are exact-match only: every component of the signed data
|
|
||||||
// (host, path, query, dimensions, format, expiration) must match exactly.
|
|
||||||
// No suffix matching, wildcard matching, or partial matching is supported.
|
|
||||||
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
|
||||||
// "other.cdn.example.com", and vice versa.
|
|
||||||
func (s *Signer) Verify(req *ImageRequest) error {
|
|
||||||
// Check expiration first
|
|
||||||
if req.Expires.IsZero() {
|
|
||||||
return ErrMissingExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(req.Expires) {
|
|
||||||
return ErrSignatureExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute expected signature
|
|
||||||
expected := s.Sign(req)
|
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
|
||||||
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
|
||||||
return ErrSignatureInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
|
||||||
// Format: "host:path:query:width:height:format:expiration"
|
|
||||||
// All components are used verbatim (exact match). No normalization,
|
|
||||||
// suffix matching, or wildcard expansion is performed.
|
|
||||||
func (s *Signer) buildSignatureData(req *ImageRequest) string {
|
|
||||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
req.SourceQuery,
|
|
||||||
req.Size.Width,
|
|
||||||
req.Size.Height,
|
|
||||||
req.Format,
|
|
||||||
req.Expires.Unix(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSignedURL creates a complete URL with signature and expiration.
|
|
||||||
// Returns the path portion that should be appended to the base URL.
|
|
||||||
func (s *Signer) GenerateSignedURL(req *ImageRequest, ttl time.Duration) (path string, sig string, exp int64) {
|
|
||||||
// Set expiration
|
|
||||||
req.Expires = time.Now().Add(ttl)
|
|
||||||
exp = req.Expires.Unix()
|
|
||||||
|
|
||||||
// Generate signature
|
|
||||||
sig = s.Sign(req)
|
|
||||||
req.Signature = sig
|
|
||||||
|
|
||||||
// Build the size component
|
|
||||||
var sizeStr string
|
|
||||||
if req.Size.OriginalSize() {
|
|
||||||
sizeStr = "orig"
|
|
||||||
} else {
|
|
||||||
sizeStr = fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the path.
|
|
||||||
// When a source query is present, it is embedded as a path segment
|
|
||||||
// (e.g. /host/path?query/size.fmt) so that ParseImagePath can extract
|
|
||||||
// it from the last-slash split. The "?" inside a path segment is
|
|
||||||
// percent-encoded by clients but chi delivers it decoded, which is
|
|
||||||
// exactly what the URL parser expects.
|
|
||||||
if req.SourceQuery != "" {
|
|
||||||
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
url.PathEscape(req.SourceQuery),
|
|
||||||
sizeStr,
|
|
||||||
req.Format,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
sizeStr,
|
|
||||||
req.Format,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path, sig, exp
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSignatureParams extracts signature and expiration from query parameters.
|
|
||||||
func ParseSignatureParams(sig, expStr string) (signature string, expires time.Time, err error) {
|
|
||||||
signature = sig
|
|
||||||
|
|
||||||
if expStr == "" {
|
|
||||||
return signature, time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expires = time.Unix(expUnix, 0)
|
|
||||||
|
|
||||||
return signature, expires, nil
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key-for-testing!")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "token=abc&v=2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter.
|
|
||||||
// The size segment must appear as the last path component.
|
|
||||||
if strings.Contains(path, "?token=abc") {
|
|
||||||
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The size segment must be present in the path
|
|
||||||
if !strings.Contains(path, "/800x600.webp") {
|
|
||||||
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path should end with the size.format, not with query params
|
|
||||||
if !strings.HasSuffix(path, "/800x600.webp") {
|
|
||||||
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key-for-testing!")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
||||||
if path != expected {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSigner_Sign(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
|
||||||
}
|
|
||||||
|
|
||||||
sig1 := signer.Sign(req)
|
|
||||||
sig2 := signer.Sign(req)
|
|
||||||
|
|
||||||
// Same input should produce same signature
|
|
||||||
if sig1 != sig2 {
|
|
||||||
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature should be non-empty
|
|
||||||
if sig1 == "" {
|
|
||||||
t.Error("Sign() produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different input should produce different signature
|
|
||||||
req2 := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/dog.jpg", // Different path
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
sig3 := signer.Sign(req2)
|
|
||||||
if sig1 == sig3 {
|
|
||||||
t.Error("Sign() produced same signature for different input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigner_Verify(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func() *ImageRequest
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "expired signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(-1 * time.Hour), // Expired
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureExpired,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
Signature: "invalid-signature",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureInvalid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing expiration",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Signature: "some-signature",
|
|
||||||
// Expires is zero
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: ErrMissingExpiration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tampered request",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
// Tamper with the request
|
|
||||||
req.SourcePath = "/photos/secret.jpg"
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureInvalid,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req := tt.setup()
|
|
||||||
err := signer.Verify(req)
|
|
||||||
|
|
||||||
if tt.wantErr == nil {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Verify() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err != tt.wantErr {
|
|
||||||
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact
|
|
||||||
// matching on every URL component. No suffix matching, wildcard matching,
|
|
||||||
// or partial matching is supported.
|
|
||||||
func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
// Base request that we'll sign, then tamper with individual fields.
|
|
||||||
baseReq := func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "token=abc",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tamper func(req *ImageRequest)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "parent domain does not match subdomain",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
// Signed for cdn.example.com, try example.com
|
|
||||||
req.SourceHost = "example.com"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "subdomain does not match parent domain",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
// Signed for cdn.example.com, try images.cdn.example.com
|
|
||||||
req.SourceHost = "images.cdn.example.com"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sibling subdomain does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
// Signed for cdn.example.com, try images.example.com
|
|
||||||
req.SourceHost = "images.example.com"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host with suffix appended does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
// Signed for cdn.example.com, try cdn.example.com.evil.com
|
|
||||||
req.SourceHost = "cdn.example.com.evil.com"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host with prefix does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
// Signed for cdn.example.com, try evilcdn.example.com
|
|
||||||
req.SourceHost = "evilcdn.example.com"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different path does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.SourcePath = "/photos/dog.jpg"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "path suffix does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.SourcePath = "/photos/cat.jpg/extra"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "path prefix does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.SourcePath = "/other/photos/cat.jpg"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different query does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.SourceQuery = "token=xyz"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "added query does not match empty query",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.SourceQuery = "extra=1"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "removed query does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.SourceQuery = ""
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different width does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.Size.Width = 801
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different height does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.Size.Height = 601
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different format does not match",
|
|
||||||
tamper: func(req *ImageRequest) {
|
|
||||||
req.Format = FormatPNG
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req := baseReq()
|
|
||||||
tt.tamper(req)
|
|
||||||
|
|
||||||
err := signer.Verify(req)
|
|
||||||
if err != ErrSignatureInvalid {
|
|
||||||
t.Errorf("Verify() = %v, want %v", err, ErrSignatureInvalid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the unmodified base request still passes
|
|
||||||
t.Run("unmodified request passes", func(t *testing.T) {
|
|
||||||
req := baseReq()
|
|
||||||
if err := signer.Verify(req); err != nil {
|
|
||||||
t.Errorf("Verify() unmodified request failed: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSigner_Sign_ExactHostInData verifies that Sign uses the exact host
|
|
||||||
// string in the signature data, producing different signatures for
|
|
||||||
// suffix-related hosts.
|
|
||||||
func TestSigner_Sign_ExactHostInData(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
hosts := []string{
|
|
||||||
"cdn.example.com",
|
|
||||||
"example.com",
|
|
||||||
"images.example.com",
|
|
||||||
"images.cdn.example.com",
|
|
||||||
"cdn.example.com.evil.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
sigs := make(map[string]string)
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: host,
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
sig := signer.Sign(req)
|
|
||||||
if existing, ok := sigs[sig]; ok {
|
|
||||||
t.Errorf("hosts %q and %q produced the same signature", existing, host)
|
|
||||||
}
|
|
||||||
|
|
||||||
sigs[sig] = host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigner_DifferentKeys(t *testing.T) {
|
|
||||||
signer1 := NewSigner("secret-key-1")
|
|
||||||
signer2 := NewSigner("secret-key-2")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign with key 1
|
|
||||||
req.Signature = signer1.Sign(req)
|
|
||||||
|
|
||||||
// Verify with key 1 should succeed
|
|
||||||
if err := signer1.Verify(req); err != nil {
|
|
||||||
t.Errorf("Verify() with same key failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify with key 2 should fail
|
|
||||||
if err := signer2.Verify(req); err != ErrSignatureInvalid {
|
|
||||||
t.Errorf("Verify() with different key should fail, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := 1 * time.Hour
|
|
||||||
path, sig, exp := signer.GenerateSignedURL(req, ttl)
|
|
||||||
|
|
||||||
// Path should be correct format
|
|
||||||
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
||||||
if path != expectedPath {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature should be non-empty
|
|
||||||
if sig == "" {
|
|
||||||
t.Error("GenerateSignedURL() produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiration should be approximately now + TTL
|
|
||||||
expTime := time.Unix(exp, 0)
|
|
||||||
expectedExp := time.Now().Add(ttl)
|
|
||||||
if expTime.Sub(expectedExp) > time.Second {
|
|
||||||
t.Errorf("GenerateSignedURL() exp time off by too much")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request should have been updated with signature and expiration
|
|
||||||
if req.Signature != sig {
|
|
||||||
t.Errorf("GenerateSignedURL() didn't update request signature")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_OrigSize(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 0, Height: 0}, // Original size
|
|
||||||
Format: FormatPNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
|
|
||||||
if path != expectedPath {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSignatureParams(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
sig string
|
|
||||||
expStr string
|
|
||||||
wantSig string
|
|
||||||
wantErr bool
|
|
||||||
checkTime bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid params",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "1704067200",
|
|
||||||
wantSig: "abc123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty expiration",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "",
|
|
||||||
wantSig: "abc123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid expiration",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "not-a-number",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sig, exp, err := ParseSignatureParams(tt.sig, tt.expStr)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Error("ParseSignatureParams() expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseSignatureParams() unexpected error = %v", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if sig != tt.wantSig {
|
|
||||||
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expStr != "" && exp.IsZero() {
|
|
||||||
t.Error("exp should not be zero when expStr is provided")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,16 @@ package imgcache
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: "v=2",
|
||||||
}
|
}
|
||||||
|
|
||||||
got := req.SourceURL()
|
got := req.SourceURL()
|
||||||
|
|
||||||
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("SourceURL() = %q, want %q", got, want)
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
@@ -17,13 +20,16 @@ func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "localhost:8080",
|
SourceHost: "localhost:8080",
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
AllowHTTP: true,
|
AllowHTTP: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
got := req.SourceURL()
|
got := req.SourceURL()
|
||||||
|
|
||||||
want := "http://localhost:8080/photos/cat.jpg"
|
want := "http://localhost:8080/photos/cat.jpg"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("SourceURL() = %q, want %q", got, want)
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
@@ -31,8 +37,10 @@ func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/img.jpg",
|
SourcePath: "/img.jpg",
|
||||||
AllowHTTP: false,
|
AllowHTTP: false,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStatsTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_HitRateIsRatio(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := setupStatsTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: dir,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set some hit/miss counts and a transform_count
|
||||||
|
_, err = db.ExecContext(ctx, `
|
||||||
|
UPDATE cache_stats
|
||||||
|
SET hit_count = 75, miss_count = 25, transform_count = 9999
|
||||||
|
WHERE id = 1
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := cache.Stats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.HitCount != 75 {
|
||||||
|
t.Errorf("HitCount = %d, want 75", stats.HitCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.MissCount != 25 {
|
||||||
|
t.Errorf("MissCount = %d, want 25", stats.MissCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HitRate should be 0.75, NOT 9999 (transform_count)
|
||||||
|
expectedRate := 0.75
|
||||||
|
if math.Abs(stats.HitRate-expectedRate) > 0.001 {
|
||||||
|
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)",
|
||||||
|
stats.HitRate, expectedRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_ZeroCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := setupStatsTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: dir,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := cache.Stats(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With zero hits and misses, HitRate should be 0, not some garbage value
|
||||||
|
if stats.HitRate != 0.0 {
|
||||||
|
t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStats_LogsFailedCountQueries verifies that a failed item count query
|
||||||
|
// and a failed size query are each logged at warn and Stats still succeeds.
|
||||||
|
func TestStats_LogsFailedCountQueries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := setupStatsTestDB(t)
|
||||||
|
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(t.Context(),
|
||||||
|
`DROP TABLE source_content; DROP TABLE variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cache.Stats(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range []string{
|
||||||
|
"failed to count cache items for stats",
|
||||||
|
"failed to sum cache size for stats",
|
||||||
|
} {
|
||||||
|
want := `"level":"WARN","msg":"` + msg + `"`
|
||||||
|
if !strings.Contains(logBuf.String(), want) {
|
||||||
|
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIncrementStats_LogsFailedUpdates verifies that a failed hit or miss
|
||||||
|
// count update and a failed upstream fetch count update are each logged at
|
||||||
|
// warn.
|
||||||
|
func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := setupStatsTestDB(t)
|
||||||
|
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(t.Context(), `DROP TABLE cache_stats`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.IncrementStats(t.Context(), false, 1024)
|
||||||
|
|
||||||
|
for _, msg := range []string{
|
||||||
|
"failed to count cache hit or miss",
|
||||||
|
"failed to count upstream fetch",
|
||||||
|
} {
|
||||||
|
want := `"level":"WARN","msg":"` + msg + `"`
|
||||||
|
if !strings.Contains(logBuf.String(), want) {
|
||||||
|
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
|
||||||
|
// TotalSizeBytes cover the stored source images and processed variants.
|
||||||
|
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
storeEvictionTestSource(t, cache, testHostCDN, testPathCat,
|
||||||
|
bytes.Repeat([]byte{0xAA}, 1000))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0xAB}, 500))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||||
|
bytes.Repeat([]byte{0xAC}, 250))
|
||||||
|
|
||||||
|
stats, err := cache.Stats(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.TotalItems != 3 {
|
||||||
|
t.Errorf("TotalItems = %d, want 3 (1 source, 2 variants)", stats.TotalItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.TotalSizeBytes != 1750 {
|
||||||
|
t.Errorf("TotalSizeBytes = %d, want 1750 (1000+500+250)",
|
||||||
|
stats.TotalSizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStats_DisabledCacheReportsNoItems verifies that a disabled disk cache
|
||||||
|
// reports no items and no size, even when its database still holds the
|
||||||
|
// rows of an earlier run with the disk cache enabled.
|
||||||
|
func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
enabled, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
storeEvictionTestSource(t, enabled, testHostCDN, testPathCat,
|
||||||
|
bytes.Repeat([]byte{0xAA}, 1000))
|
||||||
|
storeEvictionTestVariant(t, enabled, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0xAB}, 500))
|
||||||
|
|
||||||
|
disabled, err := NewCache(enabled.db, CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
DisableDiskCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := disabled.Stats(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.TotalItems != 0 || stats.TotalSizeBytes != 0 {
|
||||||
|
t.Errorf("TotalItems = %d, TotalSizeBytes = %d, want 0 and 0",
|
||||||
|
stats.TotalItems, stats.TotalSizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheStatsCounters holds the counters of the cache_stats row, in column
|
||||||
|
// order.
|
||||||
|
type cacheStatsCounters struct {
|
||||||
|
hitCount int64
|
||||||
|
missCount int64
|
||||||
|
upstreamFetchCount int64
|
||||||
|
upstreamFetchBytes int64
|
||||||
|
transformCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCacheStatsCounters reads the counters of the cache_stats row.
|
||||||
|
func readCacheStatsCounters(t *testing.T, cache *Cache) cacheStatsCounters {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got cacheStatsCounters
|
||||||
|
|
||||||
|
err := cache.db.QueryRowContext(t.Context(), `
|
||||||
|
SELECT hit_count, miss_count, upstream_fetch_count,
|
||||||
|
upstream_fetch_bytes, transform_count
|
||||||
|
FROM cache_stats WHERE id = 1
|
||||||
|
`).Scan(&got.hitCount, &got.missCount, &got.upstreamFetchCount,
|
||||||
|
&got.upstreamFetchBytes, &got.transformCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read cache_stats: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_CountsStats walks Get through a miss that fetches the
|
||||||
|
// source, a hit, a miss that reuses the cached source, and two misses whose
|
||||||
|
// source cannot be used, checking every cache_stats counter after each.
|
||||||
|
func TestService_Get_CountsStats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
// NewTestFS builds the same files the test service's fetcher serves.
|
||||||
|
testFS, _ := NewTestFS(t)
|
||||||
|
|
||||||
|
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
|
||||||
|
|
||||||
|
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
||||||
|
steps := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
size int
|
||||||
|
wantErr bool
|
||||||
|
want cacheStatsCounters
|
||||||
|
}{
|
||||||
|
{"miss that fetches the source", testPathPhoto, 50, false,
|
||||||
|
cacheStatsCounters{0, 1, 1, photoBytes, 1}},
|
||||||
|
{"hit", testPathPhoto, 50, false,
|
||||||
|
cacheStatsCounters{1, 1, 1, photoBytes, 1}},
|
||||||
|
{"miss that reuses the cached source", testPathPhoto, 25, false,
|
||||||
|
cacheStatsCounters{1, 2, 1, photoBytes, 2}},
|
||||||
|
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
|
||||||
|
cacheStatsCounters{1, 3, 2, photoBytes + fakeBytes, 2}},
|
||||||
|
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
|
||||||
|
cacheStatsCounters{1, 4, 2, photoBytes + fakeBytes, 2}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
resp, err := svc.Get(t.Context(), &ImageRequest{
|
||||||
|
SourceHost: fixtures.GoodHost,
|
||||||
|
SourcePath: step.path,
|
||||||
|
Size: Size{Width: step.size, Height: step.size},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
})
|
||||||
|
if (err != nil) != step.wantErr {
|
||||||
|
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readCacheStatsCounters(t, svc.cache)
|
||||||
|
if got != step.want {
|
||||||
|
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeUpstream answers every fetch with itself as a JPEG body. The body
|
||||||
|
// serves data, then calls cancel, when set, and returns err; io.EOF ends
|
||||||
|
// the body normally.
|
||||||
|
type fakeUpstream struct {
|
||||||
|
data *bytes.Reader
|
||||||
|
cancel context.CancelFunc
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *fakeUpstream) Fetch(
|
||||||
|
context.Context, string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
return &httpfetcher.FetchResult{
|
||||||
|
Content: io.NopCloser(u),
|
||||||
|
ContentLength: -1,
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *fakeUpstream) Read(p []byte) (int, error) {
|
||||||
|
if u.data.Len() > 0 {
|
||||||
|
return u.data.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.cancel != nil {
|
||||||
|
u.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, u.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_CountsInterruptedMisses checks every cache_stats counter
|
||||||
|
// after a miss whose request context ends during or after the upstream
|
||||||
|
// fetch, and after a miss whose upstream body is over the size limit.
|
||||||
|
func TestService_Get_CountsInterruptedMisses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||||
|
half := len(photo) / 2
|
||||||
|
|
||||||
|
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
served int // bytes of the photo the upstream body serves
|
||||||
|
cancel bool // whether the body then ends the request context
|
||||||
|
readErr error // what the body then returns
|
||||||
|
wantErr bool
|
||||||
|
want cacheStatsCounters
|
||||||
|
}{
|
||||||
|
{"request context ends during the fetch", half, true, context.Canceled, true,
|
||||||
|
cacheStatsCounters{0, 1, 1, int64(half), 0}},
|
||||||
|
{"request context ends after the fetch", len(photo), true, io.EOF, false,
|
||||||
|
cacheStatsCounters{0, 1, 1, int64(len(photo)), 1}},
|
||||||
|
{"upstream body over the size limit", half, false,
|
||||||
|
httpfetcher.ErrResponseTooLarge, true,
|
||||||
|
cacheStatsCounters{0, 1, 1, int64(half), 0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
upstream := &fakeUpstream{
|
||||||
|
data: bytes.NewReader(photo[:tc.served]),
|
||||||
|
err: tc.readErr,
|
||||||
|
}
|
||||||
|
if tc.cancel {
|
||||||
|
upstream.cancel = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.fetcher = upstream
|
||||||
|
|
||||||
|
resp, err := svc.Get(ctx, &ImageRequest{
|
||||||
|
SourceHost: fixtures.GoodHost,
|
||||||
|
SourcePath: testPathPhoto,
|
||||||
|
Size: Size{Width: 50, Height: 50},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
})
|
||||||
|
t.Logf("Get() error = %v", err)
|
||||||
|
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readCacheStatsCounters(t, svc.cache)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("counters = %+v, want %+v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_CountsHitAfterRequestEnds checks every cache_stats counter
|
||||||
|
// after a hit served with a request context that has already ended: only
|
||||||
|
// the hit count moves.
|
||||||
|
func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: fixtures.GoodHost,
|
||||||
|
SourcePath: testPathPhoto,
|
||||||
|
Size: Size{Width: 50, Height: 50},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A first request caches the variant.
|
||||||
|
resp, err := svc.Get(t.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
want := readCacheStatsCounters(t, svc.cache)
|
||||||
|
want.hitCount++
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
resp, err = svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() with an ended request context: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
if resp.CacheStatus != CacheHit {
|
||||||
|
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readCacheStatsCounters(t, svc.cache)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"math"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupStatsTestDB(t *testing.T) *sql.DB {
|
|
||||||
t.Helper()
|
|
||||||
db, err := sql.Open("sqlite", ":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStats_HitRateIsRatio(t *testing.T) {
|
|
||||||
db := setupStatsTestDB(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: dir,
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Set some hit/miss counts and a transform_count
|
|
||||||
_, err = db.ExecContext(ctx, `
|
|
||||||
UPDATE cache_stats SET hit_count = 75, miss_count = 25, transform_count = 9999 WHERE id = 1
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := cache.Stats(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats.HitCount != 75 {
|
|
||||||
t.Errorf("HitCount = %d, want 75", stats.HitCount)
|
|
||||||
}
|
|
||||||
if stats.MissCount != 25 {
|
|
||||||
t.Errorf("MissCount = %d, want 25", stats.MissCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HitRate should be 0.75, NOT 9999 (transform_count)
|
|
||||||
expectedRate := 0.75
|
|
||||||
if math.Abs(stats.HitRate-expectedRate) > 0.001 {
|
|
||||||
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)", stats.HitRate, expectedRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStats_ZeroCounts(t *testing.T) {
|
|
||||||
db := setupStatsTestDB(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: dir,
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := cache.Stats(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With zero hits and misses, HitRate should be 0, not some garbage value
|
|
||||||
if stats.HitRate != 0.0 {
|
|
||||||
t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+183
-82
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -44,7 +45,8 @@ type ContentStorage struct {
|
|||||||
|
|
||||||
// NewContentStorage creates a new content storage at the given base directory.
|
// NewContentStorage creates a new content storage at the given base directory.
|
||||||
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
||||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
|||||||
|
|
||||||
// Store writes content to storage and returns its SHA256 hash.
|
// Store writes content to storage and returns its SHA256 hash.
|
||||||
// The content is read fully into memory to compute the hash before writing.
|
// The content is read fully into memory to compute the hash before writing.
|
||||||
func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err error) {
|
func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
||||||
// Read all content to compute hash
|
// Read all content to compute hash
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,53 +64,32 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
|
|||||||
|
|
||||||
// Compute hash
|
// Compute hash
|
||||||
h := sha256.Sum256(data)
|
h := sha256.Sum256(data)
|
||||||
hash = ContentHash(hex.EncodeToString(h[:]))
|
hash := ContentHash(hex.EncodeToString(h[:]))
|
||||||
size = int64(len(data))
|
size := int64(len(data))
|
||||||
|
|
||||||
// Build path: <basedir>/<ab>/<cd>/<hash>
|
err = s.writeIfAbsent(hash, data)
|
||||||
path := s.hashToPath(hash)
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already exists
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
return hash, size, nil
|
return hash, size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory structure
|
// StoreHashed writes pre-hashed content to storage at the path derived
|
||||||
dir := filepath.Dir(path)
|
// from hash, without recomputing it. Callers that already know the
|
||||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
// hash before writing (e.g. because they must hold a hash-keyed lock
|
||||||
return "", 0, fmt.Errorf("failed to create directory: %w", err)
|
// across the whole store operation) use this instead of Store. Like
|
||||||
}
|
// Store, it is idempotent: content already on disk at that path is
|
||||||
|
// left untouched.
|
||||||
// Write to temp file first, then rename for atomicity
|
func (s *ContentStorage) StoreHashed(
|
||||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
hash ContentHash, data []byte,
|
||||||
|
) (int64, error) {
|
||||||
|
err := s.writeIfAbsent(hash, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("failed to create temp file: %w", err)
|
return 0, err
|
||||||
}
|
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
|
||||||
|
|
||||||
return "", 0, fmt.Errorf("failed to write content: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
return int64(len(data)), nil
|
||||||
return "", 0, fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic rename
|
|
||||||
//nolint:gosec // G703: paths from internal SHA256 hashes
|
|
||||||
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
|
||||||
return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash, size, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load returns a reader for the content with the given hash.
|
// Load returns a reader for the content with the given hash.
|
||||||
@@ -170,6 +151,67 @@ func (s *ContentStorage) Exists(hash ContentHash) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeIfAbsent writes data to the path derived from hash, unless
|
||||||
|
// content already exists there, via a temp-file-plus-rename so
|
||||||
|
// concurrent readers never observe a partial file.
|
||||||
|
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
||||||
|
// Build path: <basedir>/<ab>/<cd>/<hash>
|
||||||
|
path := s.hashToPath(hash)
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
if statErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory structure
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
err := os.MkdirAll(dir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temp file first, then rename for atomicity
|
||||||
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
|
// Each failure path below unlinks the temp file explicitly. This
|
||||||
|
// replaces a deferred cleanup that read a named result, which the
|
||||||
|
// canonical config does not permit; the set of paths that remove
|
||||||
|
// tmpPath, and the order relative to Close, is unchanged. This
|
||||||
|
// mirrors how MetadataStorage.Store and VariantStorage.Store below
|
||||||
|
// already express the same cleanup.
|
||||||
|
_, err = tmpFile.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to write content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// hashToPath converts a hash to a file path: <basedir>/<ab>/<cd>/<hash>
|
// hashToPath converts a hash to a file path: <basedir>/<ab>/<cd>/<hash>
|
||||||
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
||||||
h := string(hash)
|
h := string(hash)
|
||||||
@@ -188,7 +230,8 @@ type MetadataStorage struct {
|
|||||||
|
|
||||||
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
||||||
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
||||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +239,8 @@ func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SourceMetadata represents cached metadata about a source URL.
|
// SourceMetadata represents cached metadata about a source URL.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
||||||
type SourceMetadata struct {
|
type SourceMetadata struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -214,12 +259,16 @@ type SourceMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store writes metadata to storage.
|
// Store writes metadata to storage.
|
||||||
func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMetadata) error {
|
func (s *MetadataStorage) Store(
|
||||||
|
host string, pathHash PathHash, meta *SourceMetadata,
|
||||||
|
) error {
|
||||||
path := s.metaPath(host, pathHash)
|
path := s.metaPath(host, pathHash)
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
||||||
|
err := os.MkdirAll(dir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,27 +283,29 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
defer func() {
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
err = tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
//nolint:gosec // G703: paths from internal SHA256 hashes
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +313,9 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load reads metadata from storage.
|
// Load reads metadata from storage.
|
||||||
func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata, error) {
|
func (s *MetadataStorage) Load(
|
||||||
|
host string, pathHash PathHash,
|
||||||
|
) (*SourceMetadata, error) {
|
||||||
path := s.metaPath(host, pathHash)
|
path := s.metaPath(host, pathHash)
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
||||||
@@ -275,7 +328,9 @@ func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var meta SourceMetadata
|
var meta SourceMetadata
|
||||||
if err := json.Unmarshal(data, &meta); err != nil {
|
|
||||||
|
err = json.Unmarshal(data, &meta)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,9 +393,12 @@ func CacheKey(req *ImageRequest) VariantKey {
|
|||||||
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
||||||
type VariantStorage struct {
|
type VariantStorage struct {
|
||||||
baseDir string
|
baseDir string
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// VariantMeta contains metadata about a cached variant.
|
// VariantMeta contains metadata about a cached variant.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
||||||
type VariantMeta struct {
|
type VariantMeta struct {
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@@ -348,28 +406,34 @@ type VariantMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewVariantStorage creates a new variant storage at the given base directory.
|
// NewVariantStorage creates a new variant storage at the given base directory.
|
||||||
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
// A failed .meta write is logged to log.
|
||||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) {
|
||||||
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &VariantStorage{baseDir: baseDir}, nil
|
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content and metadata to storage at the given key.
|
// Store writes content and metadata to storage at the given key.
|
||||||
func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string) (size int64, err error) {
|
func (s *VariantStorage) Store(
|
||||||
|
key VariantKey, r io.Reader, contentType string,
|
||||||
|
) (int64, error) {
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to read content: %w", err)
|
return 0, fmt.Errorf("failed to read content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
size = int64(len(data))
|
size := int64(len(data))
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
metaPath := path + ".meta"
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
||||||
|
err = os.MkdirAll(dir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create directory: %w", err)
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,27 +442,29 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
defer func() {
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to write content: %w", err)
|
return 0, fmt.Errorf("failed to write content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
err = tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename content
|
// Atomic rename content
|
||||||
//nolint:gosec // G703: paths from internal SHA256 hashes
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,9 +480,11 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
|
|||||||
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(metaPath, metaData, StorageFilePerm); err != nil {
|
// Metadata write failure is non-fatal; content is already stored.
|
||||||
// Non-fatal, content is stored
|
err = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
||||||
_ = err
|
if err != nil {
|
||||||
|
s.log.Warn("failed to write variant metadata sidecar",
|
||||||
|
"path", metaPath, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return size, nil
|
return size, nil
|
||||||
@@ -438,29 +506,44 @@ 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 the given key.
|
// LoadWithSize returns a reader and file size for the content at the
|
||||||
func (s *VariantStorage) LoadWithMeta(key VariantKey) (io.ReadCloser, int64, string, error) {
|
// given key.
|
||||||
|
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load metadata for content type
|
return f, stat.Size(), nil
|
||||||
contentType := "application/octet-stream" // fallback
|
}
|
||||||
|
|
||||||
|
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||||
|
// the given key. The content type is read from the .meta file, and is
|
||||||
|
// empty when that file is missing or unreadable.
|
||||||
|
func (s *VariantStorage) LoadWithMeta(
|
||||||
|
key VariantKey,
|
||||||
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
f, size, err := s.LoadWithSize(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType string
|
||||||
|
|
||||||
|
metaPath := s.keyToPath(key) + ".meta"
|
||||||
|
|
||||||
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -470,7 +553,7 @@ func (s *VariantStorage) LoadWithMeta(key VariantKey) (io.ReadCloser, int64, str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -493,6 +576,24 @@ func (s *VariantStorage) Delete(key VariantKey) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteWithMeta removes the content at the given key together with
|
||||||
|
// its .meta sidecar file. A missing file is not an error.
|
||||||
|
func (s *VariantStorage) DeleteWithMeta(key VariantKey) error {
|
||||||
|
err := s.Delete(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metaPath := s.keyToPath(key) + ".meta"
|
||||||
|
|
||||||
|
err = os.Remove(metaPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to delete variant metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
||||||
func (s *VariantStorage) keyToPath(key VariantKey) string {
|
func (s *VariantStorage) keyToPath(key VariantKey) string {
|
||||||
k := string(key)
|
k := string(key)
|
||||||
|
|||||||
@@ -2,20 +2,27 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("hello world")
|
content := []byte("hello world")
|
||||||
|
|
||||||
hash, size, err := storage.Store(bytes.NewReader(content))
|
hash, size, err := storage.Store(bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
@@ -31,8 +38,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
|
|
||||||
// Verify file exists at expected path
|
// Verify file exists at expected path
|
||||||
hashStr := string(hash)
|
hashStr := string(hash)
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
|
||||||
|
_, err = os.Stat(expectedPath)
|
||||||
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +51,8 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
defer r.Close()
|
|
||||||
|
defer func() { _ = r.Close() }()
|
||||||
|
|
||||||
loaded, err := io.ReadAll(r)
|
loaded, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +65,10 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_StoreIdempotent(t *testing.T) {
|
func TestContentStorage_StoreIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
@@ -78,26 +92,33 @@ func TestContentStorage_StoreIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_LoadNotFound(t *testing.T) {
|
func TestContentStorage_LoadNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Load(ContentHash("nonexistent"))
|
_, err = storage.Load(ContentHash("nonexistent"))
|
||||||
if err != ErrNotFound {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_Delete(t *testing.T) {
|
func TestContentStorage_Delete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("to be deleted")
|
content := []byte("to be deleted")
|
||||||
|
|
||||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
hash, _, err := storage.Store(bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
@@ -107,7 +128,8 @@ func TestContentStorage_Delete(t *testing.T) {
|
|||||||
t.Error("Exists() = false, want true")
|
t.Error("Exists() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := storage.Delete(hash); err != nil {
|
err = storage.Delete(hash)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Delete() error = %v", err)
|
t.Fatalf("Delete() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,20 +139,27 @@ func TestContentStorage_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_DeleteNonexistent(t *testing.T) {
|
func TestContentStorage_DeleteNonexistent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not error
|
// Should not error
|
||||||
if err := storage.Delete(ContentHash("nonexistent")); err != nil {
|
err = storage.Delete(ContentHash("nonexistent"))
|
||||||
|
if err != nil {
|
||||||
t.Errorf("Delete() error = %v, want nil", err)
|
t.Errorf("Delete() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_HashToPath(t *testing.T) {
|
func TestContentStorage_HashToPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
@@ -138,50 +167,59 @@ func TestContentStorage_HashToPath(t *testing.T) {
|
|||||||
|
|
||||||
// Test by storing and verifying the resulting path structure
|
// Test by storing and verifying the resulting path structure
|
||||||
content := []byte("test content for path verification")
|
content := []byte("test content for path verification")
|
||||||
|
|
||||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
hash, _, err := storage.Store(bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hashStr := string(hash)
|
hashStr := string(hash)
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
|
||||||
|
_, err = os.Stat(expectedPath)
|
||||||
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
storage, err := NewMetadataStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := &SourceMetadata{
|
meta := &SourceMetadata{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
ContentHash: "abc123",
|
ContentHash: "abc123",
|
||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
ContentType: "image/jpeg",
|
ContentType: testContentTypeJPEG,
|
||||||
FetchedAt: 1704067200,
|
FetchedAt: 1704067200,
|
||||||
ETag: `"etag123"`,
|
ETag: `"etag123"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
pathHash := HashPath("/photos/cat.jpg")
|
pathHash := HashPath(testPathCat)
|
||||||
|
|
||||||
err = storage.Store("cdn.example.com", pathHash, meta)
|
err = storage.Store(testHostCDN, pathHash, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file exists at expected path
|
// Verify file exists at expected path
|
||||||
expectedPath := filepath.Join(tmpDir, "cdn.example.com", string(pathHash)+".json")
|
expectedPath := filepath.Join(tmpDir, testHostCDN, string(pathHash)+".json")
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
|
||||||
|
_, err = os.Stat(expectedPath)
|
||||||
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and verify
|
// Load and verify
|
||||||
loaded, err := storage.Load("cdn.example.com", pathHash)
|
loaded, err := storage.Load(testHostCDN, pathHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -208,55 +246,64 @@ func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_LoadNotFound(t *testing.T) {
|
func TestMetadataStorage_LoadNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
storage, err := NewMetadataStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Load("example.com", PathHash("nonexistent"))
|
_, err = storage.Load(testHostExample, PathHash("nonexistent"))
|
||||||
if err != ErrNotFound {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_Delete(t *testing.T) {
|
func TestMetadataStorage_Delete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
storage, err := NewMetadataStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := &SourceMetadata{
|
meta := &SourceMetadata{
|
||||||
Host: "example.com",
|
Host: testHostExample,
|
||||||
Path: "/test.jpg",
|
Path: "/test.jpg",
|
||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
}
|
}
|
||||||
|
|
||||||
pathHash := HashPath("/test.jpg")
|
pathHash := HashPath("/test.jpg")
|
||||||
|
|
||||||
err = storage.Store("example.com", pathHash, meta)
|
err = storage.Store(testHostExample, pathHash, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !storage.Exists("example.com", pathHash) {
|
if !storage.Exists(testHostExample, pathHash) {
|
||||||
t.Error("Exists() = false, want true")
|
t.Error("Exists() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := storage.Delete("example.com", pathHash); err != nil {
|
err = storage.Delete(testHostExample, pathHash)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Delete() error = %v", err)
|
t.Fatalf("Delete() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if storage.Exists("example.com", pathHash) {
|
if storage.Exists(testHostExample, pathHash) {
|
||||||
t.Error("Exists() = true after delete, want false")
|
t.Error("Exists() = true after delete, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashPath(t *testing.T) {
|
func TestHashPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Same input should produce same hash
|
// Same input should produce same hash
|
||||||
hash1 := HashPath("/photos/cat.jpg")
|
hash1 := HashPath(testPathCat)
|
||||||
hash2 := HashPath("/photos/cat.jpg")
|
hash2 := HashPath(testPathCat)
|
||||||
|
|
||||||
if hash1 != hash2 {
|
if hash1 != hash2 {
|
||||||
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
|
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
|
||||||
@@ -276,9 +323,11 @@ func TestHashPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheKey(t *testing.T) {
|
func TestCacheKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req1 := &ImageRequest{
|
req1 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -287,8 +336,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req2 := &ImageRequest{
|
req2 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -311,8 +360,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
|
|
||||||
// Different size should produce different key
|
// Different size should produce different key
|
||||||
req3 := &ImageRequest{
|
req3 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 400, Height: 300}, // Different size
|
Size: Size{Width: 400, Height: 300}, // Different size
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -327,8 +376,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
|
|
||||||
// Different format should produce different key
|
// Different format should produce different key
|
||||||
req4 := &ImageRequest{
|
req4 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatPNG, // Different format
|
Format: FormatPNG, // Different format
|
||||||
@@ -343,8 +392,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
|
|
||||||
// Different quality should produce different key
|
// Different quality should produce different key
|
||||||
req5 := &ImageRequest{
|
req5 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -357,3 +406,35 @@ func TestCacheKey(t *testing.T) {
|
|||||||
t.Error("CacheKey() produced same key for different quality")
|
t.Error("CacheKey() produced same key for different quality")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
|
||||||
|
// that fails is logged at warn and the store still succeeds.
|
||||||
|
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
storage, err := NewVariantStorage(
|
||||||
|
t.TempDir(), slog.New(slog.NewJSONHandler(&logBuf, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVariantStorage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := CacheKey(&ImageRequest{SourceHost: testHostCDN, SourcePath: testPathCat})
|
||||||
|
|
||||||
|
// A directory where the .meta file goes makes the .meta write fail.
|
||||||
|
err = os.MkdirAll(storage.keyToPath(key)+".meta", StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `"level":"WARN","msg":"failed to write variant metadata sidecar"`
|
||||||
|
if !strings.Contains(logBuf.String(), want) {
|
||||||
|
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,16 +15,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared test data literals, extracted as constants for goconst.
|
||||||
|
const (
|
||||||
|
testHostCDN = "cdn.example.com"
|
||||||
|
testHostExample = "example.com"
|
||||||
|
testPathCat = "/photos/cat.jpg"
|
||||||
|
testContentTypeJPEG = "image/jpeg"
|
||||||
|
testContentTypeWebP = "image/webp"
|
||||||
|
testHeaderContentType = "Content-Type"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFixtures contains paths to test files in the mock filesystem.
|
// TestFixtures contains paths to test files in the mock filesystem.
|
||||||
type TestFixtures struct {
|
type TestFixtures struct {
|
||||||
// Valid image files
|
// Valid image files
|
||||||
GoodHostJPEG string // whitelisted host, valid JPEG
|
GoodHostJPEG string // allowlisted host, valid JPEG
|
||||||
GoodHostPNG string // whitelisted host, valid PNG
|
GoodHostPNG string // allowlisted host, valid PNG
|
||||||
GoodHostGIF string // whitelisted host, valid GIF
|
GoodHostGIF string // allowlisted host, valid GIF
|
||||||
OtherHostJPEG string // non-whitelisted host, valid JPEG
|
OtherHostJPEG string // non-allowlisted host, valid JPEG
|
||||||
OtherHostPNG string // non-whitelisted host, valid PNG
|
OtherHostPNG string // non-allowlisted host, valid PNG
|
||||||
|
|
||||||
// Invalid/edge case files
|
// Invalid/edge case files
|
||||||
InvalidFile string // file with wrong magic bytes
|
InvalidFile string // file with wrong magic bytes
|
||||||
@@ -32,8 +43,8 @@ type TestFixtures struct {
|
|||||||
TextFile string // text file masquerading as image
|
TextFile string // text file masquerading as image
|
||||||
|
|
||||||
// Hostnames
|
// Hostnames
|
||||||
GoodHost string // whitelisted hostname
|
GoodHost string // allowlisted hostname
|
||||||
OtherHost string // non-whitelisted hostname
|
OtherHost string // non-allowlisted hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultFixtures returns the standard test fixture paths.
|
// DefaultFixtures returns the standard test fixture paths.
|
||||||
@@ -88,14 +99,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,14 +120,16 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test PNG: %v", err)
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,15 +140,20 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White})
|
img := image.NewPaletted(
|
||||||
for y := 0; y < height; y++ {
|
image.Rect(0, 0, width, height),
|
||||||
for x := 0; x < width; x++ {
|
[]color.Color{c, color.White},
|
||||||
|
)
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
img.SetColorIndex(x, y, 0)
|
img.SetColorIndex(x, y, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := gif.Encode(&buf, img, nil); err != nil {
|
|
||||||
|
err := gif.Encode(&buf, img, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test GIF: %v", err)
|
t.Fatalf("failed to encode test GIF: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +161,15 @@ func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetupTestService creates a Service with mock fetcher for testing.
|
// SetupTestService creates a Service with mock fetcher for testing.
|
||||||
func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) {
|
func SetupTestService(
|
||||||
|
t *testing.T, opts ...TestServiceOption,
|
||||||
|
) (*Service, *TestFixtures) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
mockFS, fixtures := NewTestFS(t)
|
mockFS, fixtures := NewTestFS(t)
|
||||||
|
|
||||||
cfg := &testServiceConfig{
|
cfg := &testServiceConfig{
|
||||||
whitelist: []string{fixtures.GoodHost},
|
allowlist: []string{fixtures.GoodHost},
|
||||||
signingKey: "test-signing-key-must-be-32-chars",
|
signingKey: "test-signing-key-must-be-32-chars",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,9 +194,9 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
|
|||||||
|
|
||||||
svc, err := NewService(&ServiceConfig{
|
svc, err := NewService(&ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: NewMockFetcher(mockFS),
|
Fetcher: httpfetcher.NewMock(mockFS),
|
||||||
SigningKey: cfg.signingKey,
|
SigningKey: cfg.signingKey,
|
||||||
Whitelist: cfg.whitelist,
|
Allowlist: cfg.allowlist,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -194,7 +216,8 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the real production schema via migrations
|
// Use the real production schema via migrations
|
||||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,17 +225,17 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type testServiceConfig struct {
|
type testServiceConfig struct {
|
||||||
whitelist []string
|
allowlist []string
|
||||||
signingKey string
|
signingKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServiceOption configures the test service.
|
// TestServiceOption configures the test service.
|
||||||
type TestServiceOption func(*testServiceConfig)
|
type TestServiceOption func(*testServiceConfig)
|
||||||
|
|
||||||
// WithWhitelist sets the whitelist for the test service.
|
// WithAllowlist sets the allowlist for the test service.
|
||||||
func WithWhitelist(hosts ...string) TestServiceOption {
|
func WithAllowlist(hosts ...string) TestServiceOption {
|
||||||
return func(c *testServiceConfig) {
|
return func(c *testServiceConfig) {
|
||||||
c.whitelist = hosts
|
c.allowlist = hosts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,9 +246,9 @@ func WithSigningKey(key string) TestServiceOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithNoWhitelist removes all whitelisted hosts.
|
// WithNoAllowlist removes all allowlisted hosts.
|
||||||
func WithNoWhitelist() TestServiceOption {
|
func WithNoAllowlist() TestServiceOption {
|
||||||
return func(c *testServiceConfig) {
|
return func(c *testServiceConfig) {
|
||||||
c.whitelist = nil
|
c.allowlist = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,21 @@ var (
|
|||||||
// MaxDimension is the maximum allowed width or height.
|
// MaxDimension is the maximum allowed width or height.
|
||||||
const MaxDimension = 8192
|
const MaxDimension = 8192
|
||||||
|
|
||||||
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
|
// "height") appears in the error. 0 means "original size" and is valid.
|
||||||
|
func ValidateDimension(name string, value int) error {
|
||||||
|
if value < 0 {
|
||||||
|
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value > MaxDimension {
|
||||||
|
return fmt.Errorf("%w: %s is above %d",
|
||||||
|
ErrDimensionTooLarge, name, MaxDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
||||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
||||||
|
|
||||||
@@ -40,7 +55,8 @@ type ParsedURL struct {
|
|||||||
Format ImageFormat
|
Format ImageFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImagePath parses the path captured by chi's wildcard: <host>/<path>/<size>.<format>
|
// ParseImagePath parses the path captured by chi's wildcard:
|
||||||
|
// <host>/<path>/<size>.<format>
|
||||||
// This is the primary entry point when using chi routing.
|
// This is the primary entry point when using chi routing.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
@@ -76,7 +92,8 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||||
// Check for path traversal before any other processing
|
// Check for path traversal before any other processing
|
||||||
if err := checkPathTraversal(remainder); err != nil {
|
err := checkPathTraversal(remainder)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +119,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
// Split host from path
|
// Split host from path
|
||||||
// The first segment is the host, everything after is the path
|
// The first segment is the host, everything after is the path
|
||||||
firstSlash := strings.Index(hostAndPath, "/")
|
firstSlash := strings.Index(hostAndPath, "/")
|
||||||
|
|
||||||
var host, path, query string
|
var host, path, query string
|
||||||
|
|
||||||
if firstSlash == -1 {
|
if firstSlash == -1 {
|
||||||
@@ -181,8 +199,7 @@ func checkPathTraversal(path string) error {
|
|||||||
|
|
||||||
// Also check for ".." as a path segment in the original path
|
// Also check for ".." as a path segment in the original path
|
||||||
// This catches cases where the path hasn't been normalized
|
// This catches cases where the path hasn't been normalized
|
||||||
segments := strings.Split(path, "/")
|
for seg := range strings.SplitSeq(path, "/") {
|
||||||
for _, seg := range segments {
|
|
||||||
// URL decode the segment
|
// URL decode the segment
|
||||||
decodedSeg, _ := url.PathUnescape(seg)
|
decodedSeg, _ := url.PathUnescape(seg)
|
||||||
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
||||||
@@ -202,8 +219,10 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
}
|
}
|
||||||
|
|
||||||
var size Size
|
var (
|
||||||
var formatStr string
|
size Size
|
||||||
|
formatStr string
|
||||||
|
)
|
||||||
|
|
||||||
if matches[4] == "orig" {
|
if matches[4] == "orig" {
|
||||||
// "orig.format" pattern
|
// "orig.format" pattern
|
||||||
@@ -221,14 +240,20 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
}
|
}
|
||||||
|
|
||||||
if width > MaxDimension || height > MaxDimension {
|
|
||||||
return Size{}, "", ErrDimensionTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
size = Size{Width: width, Height: height}
|
size = Size{Width: width, Height: height}
|
||||||
formatStr = matches[3]
|
formatStr = matches[3]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := ValidateDimension("width", size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
format, err := parseFormat(formatStr)
|
format, err := parseFormat(formatStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
|
|||||||
+101
-87
@@ -1,93 +1,124 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// assertParsedURL compares all fields of a parsed URL against the
|
||||||
|
// expected value.
|
||||||
|
func assertParsedURL(t *testing.T, got, want *ParsedURL) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got.Host != want.Host {
|
||||||
|
t.Errorf("Host = %q, want %q", got.Host, want.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Path != want.Path {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, want.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Query != want.Query {
|
||||||
|
t.Errorf("Query = %q, want %q", got.Query, want.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Size != want.Size {
|
||||||
|
t.Errorf("Size = %v, want %v", got.Size, want.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Format != want.Format {
|
||||||
|
t.Errorf("Format = %q, want %q", got.Format, want.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseImageURL(t *testing.T) {
|
func TestParseImageURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want *ParsedURL
|
want *ParsedURL
|
||||||
wantErr error
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic path with size",
|
name: "basic path with size",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat,
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with 0x0",
|
name: "original size with 0x0",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat,
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 0, Height: 0}, Format: FormatJPEG,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 0, Height: 0},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with orig keyword",
|
name: "original size with orig keyword",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat,
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 0, Height: 0}, Format: FormatPNG,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 0, Height: 0},
|
|
||||||
Format: FormatPNG,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "path with query string",
|
name: "path with query string",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
Query: "arg1=val1&arg2=val2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deep nested path",
|
name: "deep nested path",
|
||||||
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: "/a/b/c/d/image.jpg",
|
||||||
Path: "/a/b/c/d/image.jpg",
|
Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 1920, Height: 1080},
|
|
||||||
Format: FormatAVIF,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "jpg alias for jpeg",
|
name: "jpg alias for jpeg",
|
||||||
input: "/v1/image/example.com/img.png/100x100.jpg",
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "example.com",
|
Host: testHostExample, Path: "/img.png",
|
||||||
Path: "/img.png",
|
Size: Size{Width: 100, Height: 100}, Format: FormatJPEG,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 100, Height: 100},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gif format",
|
name: "gif format",
|
||||||
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "example.com",
|
Host: testHostExample, Path: "/animated.gif",
|
||||||
Path: "/animated.gif",
|
Size: Size{Width: 200, Height: 200}, Format: FormatGIF,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 200, Height: 200},
|
|
||||||
Format: FormatGIF,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImageURL(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertParsedURL(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageURL_Errors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
name: "missing prefix",
|
name: "missing prefix",
|
||||||
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
||||||
@@ -122,47 +153,23 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := ParseImageURL(tt.input)
|
t.Parallel()
|
||||||
|
|
||||||
if tt.wantErr != nil {
|
_, err := ParseImageURL(tt.input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !errorIs(err, tt.wantErr) {
|
if !errorIs(err, tt.wantErr) {
|
||||||
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseImageURL() unexpected error = %v", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.Host != tt.want.Host {
|
|
||||||
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
|
|
||||||
}
|
|
||||||
if got.Path != tt.want.Path {
|
|
||||||
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
|
|
||||||
}
|
|
||||||
if got.Query != tt.want.Query {
|
|
||||||
t.Errorf("Query = %q, want %q", got.Query, tt.want.Query)
|
|
||||||
}
|
|
||||||
if got.Size != tt.want.Size {
|
|
||||||
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
|
|
||||||
}
|
|
||||||
if got.Format != tt.want.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath(t *testing.T) {
|
func TestParseImagePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -174,8 +181,8 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
name: "chi wildcard capture",
|
name: "chi wildcard capture",
|
||||||
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
},
|
},
|
||||||
@@ -184,8 +191,8 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
name: "with leading slash from chi",
|
name: "with leading slash from chi",
|
||||||
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
},
|
},
|
||||||
@@ -194,35 +201,30 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got, err := ParseImagePath(tt.input)
|
got, err := ParseImagePath(tt.input)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if got.Host != tt.want.Host {
|
|
||||||
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
|
assertParsedURL(t, got, tt.want)
|
||||||
}
|
|
||||||
if got.Path != tt.want.Path {
|
|
||||||
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
|
|
||||||
}
|
|
||||||
if got.Size != tt.want.Size {
|
|
||||||
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
|
|
||||||
}
|
|
||||||
if got.Format != tt.want.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsedURL_ToImageRequest(t *testing.T) {
|
func TestParsedURL_ToImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
parsed := &ParsedURL{
|
parsed := &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
Query: "version=2",
|
Query: "version=2",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -233,21 +235,27 @@ func TestParsedURL_ToImageRequest(t *testing.T) {
|
|||||||
if req.SourceHost != parsed.Host {
|
if req.SourceHost != parsed.Host {
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != parsed.Path {
|
if req.SourcePath != parsed.Path {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != parsed.Query {
|
if req.SourceQuery != parsed.Query {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size != parsed.Size {
|
if req.Size != parsed.Size {
|
||||||
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Format != parsed.Format {
|
if req.Format != parsed.Format {
|
||||||
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImageURL_PathTraversal(t *testing.T) {
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// All path traversal attempts should be rejected
|
// All path traversal attempts should be rejected
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -293,12 +301,14 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
_, err := ParseImageURL(tt.input)
|
_, err := ParseImageURL(tt.input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ParseImageURL() should reject path traversal attempts")
|
t.Error("ParseImageURL() should reject path traversal attempts")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrPathTraversal {
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -306,6 +316,8 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath_PathTraversal(t *testing.T) {
|
func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Test path traversal via ParseImagePath (chi wildcard)
|
// Test path traversal via ParseImagePath (chi wildcard)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -323,12 +335,14 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
_, err := ParseImagePath(tt.input)
|
_, err := ParseImagePath(tt.input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ParseImagePath() should reject path traversal attempts")
|
t.Error("ParseImagePath() should reject path traversal attempts")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrPathTraversal {
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -337,7 +351,7 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
// errorIs checks if err matches target (handles wrapped errors).
|
// errorIs checks if err matches target (handles wrapped errors).
|
||||||
func errorIs(err, target error) bool {
|
func errorIs(err, target error) bool {
|
||||||
if err == target {
|
if errors.Is(err, target) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check if error message contains target message for wrapped errors
|
// Check if error message contains target message for wrapped errors
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req ImageRequest
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "within bounds",
|
||||||
|
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size and empty fit",
|
||||||
|
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "width over limit",
|
||||||
|
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
||||||
|
wantErr: ErrDimensionTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "height over limit",
|
||||||
|
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
||||||
|
wantErr: ErrDimensionTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative width",
|
||||||
|
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
||||||
|
wantErr: ErrInvalidSize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid fit mode",
|
||||||
|
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
||||||
|
wantErr: ErrInvalidFitMode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := ValidateImageRequest(&tt.req)
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
// Params defines dependencies for Logger.
|
// Params defines dependencies for Logger.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -46,6 +46,10 @@ const (
|
|||||||
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
||||||
const MinMagicBytes = 12
|
const MinMagicBytes = 12
|
||||||
|
|
||||||
|
// mimeOctetStream is the fallback MIME type for formats without a
|
||||||
|
// specific MIME type.
|
||||||
|
const mimeOctetStream = "application/octet-stream"
|
||||||
|
|
||||||
// Magic byte signatures for supported formats.
|
// Magic byte signatures for supported formats.
|
||||||
// These are effectively constants but Go doesn't support const slices.
|
// These are effectively constants but Go doesn't support const slices.
|
||||||
//
|
//
|
||||||
@@ -190,14 +194,17 @@ func IsSupportedMIMEType(mimeType string) bool {
|
|||||||
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
|
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
|
||||||
// Read minimum bytes for detection
|
// Read minimum bytes for detection
|
||||||
buf := make([]byte, MinMagicBytes)
|
buf := make([]byte, MinMagicBytes)
|
||||||
|
|
||||||
n, err := io.ReadFull(r, buf)
|
n, err := io.ReadFull(r, buf)
|
||||||
if err != nil && err != io.ErrUnexpectedEOF {
|
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf = buf[:n]
|
buf = buf[:n]
|
||||||
|
|
||||||
// Validate magic bytes
|
// Validate magic bytes
|
||||||
if err := ValidateMagicBytes(buf, declaredType); err != nil {
|
err = ValidateMagicBytes(buf, declaredType)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +226,9 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
|||||||
return FormatGIF, true
|
return FormatGIF, true
|
||||||
case MIMETypeAVIF:
|
case MIMETypeAVIF:
|
||||||
return FormatAVIF, true
|
return FormatAVIF, true
|
||||||
|
case MIMETypeSVG:
|
||||||
|
// SVG has no corresponding output format.
|
||||||
|
return "", false
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -237,7 +247,10 @@ func ImageFormatToMIME(format ImageFormat) string {
|
|||||||
return string(MIMETypeGIF)
|
return string(MIMETypeGIF)
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return string(MIMETypeAVIF)
|
return string(MIMETypeAVIF)
|
||||||
|
case FormatOriginal:
|
||||||
|
// Original format passes content through unchanged.
|
||||||
|
return mimeOctetStream
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return mimeOctetStream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,121 +2,90 @@ package magic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared test fixture strings.
|
||||||
|
const (
|
||||||
|
testNameEmpty = "empty"
|
||||||
|
testMIMEJPEG = "image/jpeg"
|
||||||
|
testMIMEJPEGParams = "image/jpeg; charset=utf-8"
|
||||||
|
testMIMEPNG = "image/png"
|
||||||
|
testMIMEWebP = "image/webp"
|
||||||
|
testMIMEGIF = "image/gif"
|
||||||
|
testMIMEAVIF = "image/avif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pad appends zero bytes so data is comfortably above MinMagicBytes.
|
||||||
|
func pad(b ...byte) []byte {
|
||||||
|
return append(b, make([]byte, 100)...)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectFormat(t *testing.T) {
|
func TestDetectFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
jpeg := pad(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01)
|
||||||
|
png := pad(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D)
|
||||||
|
gif87a := pad(0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0, 0, 0, 0, 0, 0)
|
||||||
|
gif89a := pad(0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0)
|
||||||
|
// RIFF + size placeholder + WEBP
|
||||||
|
webp := pad(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)
|
||||||
|
// box size + ftyp + brand
|
||||||
|
avif := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66)
|
||||||
|
avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
wantMIME MIMEType
|
wantMIME MIMEType
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{name: "JPEG", data: jpeg, wantMIME: MIMETypeJPEG},
|
||||||
name: "JPEG",
|
{name: "PNG", data: png, wantMIME: MIMETypePNG},
|
||||||
data: append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, make([]byte, 100)...),
|
{name: "GIF87a", data: gif87a, wantMIME: MIMETypeGIF},
|
||||||
wantMIME: MIMETypeJPEG,
|
{name: "GIF89a", data: gif89a, wantMIME: MIMETypeGIF},
|
||||||
wantErr: nil,
|
{name: "WebP", data: webp, wantMIME: MIMETypeWebP},
|
||||||
},
|
{name: "AVIF", data: avif, wantMIME: MIMETypeAVIF},
|
||||||
{
|
{name: "AVIF sequence", data: avis, wantMIME: MIMETypeAVIF},
|
||||||
name: "PNG",
|
|
||||||
data: append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypePNG,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GIF87a",
|
|
||||||
data: append([]byte{0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeGIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GIF89a",
|
|
||||||
data: append([]byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeGIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "WebP",
|
|
||||||
data: append([]byte{
|
|
||||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
|
||||||
0x00, 0x00, 0x00, 0x00, // file size (placeholder)
|
|
||||||
0x57, 0x45, 0x42, 0x50, // WEBP
|
|
||||||
}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeWebP,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AVIF",
|
|
||||||
data: append([]byte{
|
|
||||||
0x00, 0x00, 0x00, 0x1C, // box size
|
|
||||||
0x66, 0x74, 0x79, 0x70, // ftyp
|
|
||||||
0x61, 0x76, 0x69, 0x66, // avif brand
|
|
||||||
}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeAVIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AVIF sequence",
|
|
||||||
data: append([]byte{
|
|
||||||
0x00, 0x00, 0x00, 0x1C, // box size
|
|
||||||
0x66, 0x74, 0x79, 0x70, // ftyp
|
|
||||||
0x61, 0x76, 0x69, 0x73, // avis brand
|
|
||||||
}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeAVIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "SVG with XML declaration",
|
name: "SVG with XML declaration",
|
||||||
data: []byte(`<?xml version="1.0"?><svg></svg>`),
|
data: []byte(`<?xml version="1.0"?><svg></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SVG without declaration",
|
name: "SVG without declaration",
|
||||||
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SVG with whitespace",
|
name: "SVG with whitespace",
|
||||||
data: []byte(` <?xml version="1.0"?><svg></svg>`),
|
data: []byte(` <?xml version="1.0"?><svg></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SVG with BOM",
|
name: "SVG with BOM",
|
||||||
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
|
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown format",
|
name: "unknown format",
|
||||||
data: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
data: make([]byte, MinMagicBytes),
|
||||||
wantMIME: "",
|
|
||||||
wantErr: ErrUnknownFormat,
|
wantErr: ErrUnknownFormat,
|
||||||
},
|
},
|
||||||
{
|
{name: "too short", data: []byte{0xFF, 0xD8}, wantErr: ErrNotEnoughData},
|
||||||
name: "too short",
|
{name: testNameEmpty, data: []byte{}, wantErr: ErrNotEnoughData},
|
||||||
data: []byte{0xFF, 0xD8},
|
|
||||||
wantMIME: "",
|
|
||||||
wantErr: ErrNotEnoughData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
data: []byte{},
|
|
||||||
wantMIME: "",
|
|
||||||
wantErr: ErrNotEnoughData,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := DetectFormat(tt.data)
|
t.Parallel()
|
||||||
|
|
||||||
if err != tt.wantErr {
|
got, err := DetectFormat(tt.data)
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
t.Errorf("DetectFormat() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("DetectFormat() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -130,8 +99,10 @@ func TestDetectFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateMagicBytes(t *testing.T) {
|
func TestValidateMagicBytes(t *testing.T) {
|
||||||
jpegData := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, make([]byte, 100)...)
|
t.Parallel()
|
||||||
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, make([]byte, 100)...)
|
|
||||||
|
jpegData := pad(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01)
|
||||||
|
pngData := pad(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -142,40 +113,42 @@ func TestValidateMagicBytes(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "matching JPEG",
|
name: "matching JPEG",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/jpeg",
|
declaredType: testMIMEJPEG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matching JPEG with params",
|
name: "matching JPEG with params",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/jpeg; charset=utf-8",
|
declaredType: testMIMEJPEGParams,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matching PNG",
|
name: "matching PNG",
|
||||||
data: pngData,
|
data: pngData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mismatched type",
|
name: "mismatched type",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: ErrMagicByteMismatch,
|
wantErr: ErrMagicByteMismatch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown data",
|
name: "unknown data",
|
||||||
data: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
data: make([]byte, MinMagicBytes),
|
||||||
declaredType: "image/jpeg",
|
declaredType: testMIMEJPEG,
|
||||||
wantErr: ErrUnknownFormat,
|
wantErr: ErrUnknownFormat,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
err := ValidateMagicBytes(tt.data, tt.declaredType)
|
err := ValidateMagicBytes(tt.data, tt.declaredType)
|
||||||
|
|
||||||
if err != tt.wantErr {
|
if !errors.Is(err, tt.wantErr) {
|
||||||
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -183,27 +156,31 @@ func TestValidateMagicBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIsSupportedMIMEType(t *testing.T) {
|
func TestIsSupportedMIMEType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
mimeType string
|
mimeType string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"image/jpeg", true},
|
{testMIMEJPEG, true},
|
||||||
{"image/png", true},
|
{testMIMEPNG, true},
|
||||||
{"image/webp", true},
|
{testMIMEWebP, true},
|
||||||
{"image/gif", true},
|
{testMIMEGIF, true},
|
||||||
{"image/avif", true},
|
{testMIMEAVIF, true},
|
||||||
{"image/svg+xml", true},
|
{"image/svg+xml", true},
|
||||||
{"IMAGE/JPEG", true},
|
{"IMAGE/JPEG", true},
|
||||||
{"image/jpeg; charset=utf-8", true},
|
{testMIMEJPEGParams, true},
|
||||||
{"image/tiff", false},
|
{"image/tiff", false},
|
||||||
{"image/bmp", false},
|
{"image/bmp", false},
|
||||||
{"application/octet-stream", false},
|
{mimeOctetStream, false},
|
||||||
{"text/plain", false},
|
{"text/plain", false},
|
||||||
{"", false},
|
{"", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.mimeType, func(t *testing.T) {
|
t.Run(tt.mimeType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
if got := IsSupportedMIMEType(tt.mimeType); got != tt.want {
|
if got := IsSupportedMIMEType(tt.mimeType); got != tt.want {
|
||||||
t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -212,8 +189,16 @@ func TestIsSupportedMIMEType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPeekAndValidate(t *testing.T) {
|
func TestPeekAndValidate(t *testing.T) {
|
||||||
jpegData := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, []byte("rest of jpeg data")...)
|
t.Parallel()
|
||||||
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, []byte("rest of png data")...)
|
|
||||||
|
jpegMagic := []byte{
|
||||||
|
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||||
|
}
|
||||||
|
pngMagic := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||||
|
}
|
||||||
|
jpegData := slices.Concat(jpegMagic, []byte("rest of jpeg data"))
|
||||||
|
pngData := slices.Concat(pngMagic, []byte("rest of png data"))
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -225,30 +210,32 @@ func TestPeekAndValidate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid JPEG",
|
name: "valid JPEG",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/jpeg",
|
declaredType: testMIMEJPEG,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantData: jpegData,
|
wantData: jpegData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid PNG",
|
name: "valid PNG",
|
||||||
data: pngData,
|
data: pngData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantData: pngData,
|
wantData: pngData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mismatched type",
|
name: "mismatched type",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
r := bytes.NewReader(tt.data)
|
t.Parallel()
|
||||||
result, err := PeekAndValidate(r, tt.declaredType)
|
|
||||||
|
|
||||||
|
r := bytes.NewReader(tt.data)
|
||||||
|
|
||||||
|
result, err := PeekAndValidate(r, tt.declaredType)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("PeekAndValidate() expected error, got nil")
|
t.Error("PeekAndValidate() expected error, got nil")
|
||||||
@@ -272,23 +259,28 @@ func TestPeekAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !bytes.Equal(got, tt.wantData) {
|
if !bytes.Equal(got, tt.wantData) {
|
||||||
t.Errorf("PeekAndValidate() data mismatch: got %d bytes, want %d bytes", len(got), len(tt.wantData))
|
t.Errorf(
|
||||||
|
"PeekAndValidate() data mismatch: got %d bytes, want %d bytes",
|
||||||
|
len(got), len(tt.wantData),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMIMEToImageFormat(t *testing.T) {
|
func TestMIMEToImageFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
mimeType string
|
mimeType string
|
||||||
wantFormat ImageFormat
|
wantFormat ImageFormat
|
||||||
wantOk bool
|
wantOk bool
|
||||||
}{
|
}{
|
||||||
{"image/jpeg", FormatJPEG, true},
|
{testMIMEJPEG, FormatJPEG, true},
|
||||||
{"image/png", FormatPNG, true},
|
{testMIMEPNG, FormatPNG, true},
|
||||||
{"image/webp", FormatWebP, true},
|
{testMIMEWebP, FormatWebP, true},
|
||||||
{"image/gif", FormatGIF, true},
|
{testMIMEGIF, FormatGIF, true},
|
||||||
{"image/avif", FormatAVIF, true},
|
{testMIMEAVIF, FormatAVIF, true},
|
||||||
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
|
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
|
||||||
{"image/tiff", "", false},
|
{"image/tiff", "", false},
|
||||||
{"text/plain", "", false},
|
{"text/plain", "", false},
|
||||||
@@ -296,6 +288,8 @@ func TestMIMEToImageFormat(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.mimeType, func(t *testing.T) {
|
t.Run(tt.mimeType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got, ok := MIMEToImageFormat(tt.mimeType)
|
got, ok := MIMEToImageFormat(tt.mimeType)
|
||||||
|
|
||||||
if ok != tt.wantOk {
|
if ok != tt.wantOk {
|
||||||
@@ -310,21 +304,25 @@ func TestMIMEToImageFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageFormatToMIME(t *testing.T) {
|
func TestImageFormatToMIME(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
format ImageFormat
|
format ImageFormat
|
||||||
wantMIME string
|
wantMIME string
|
||||||
}{
|
}{
|
||||||
{FormatJPEG, "image/jpeg"},
|
{FormatJPEG, testMIMEJPEG},
|
||||||
{FormatPNG, "image/png"},
|
{FormatPNG, testMIMEPNG},
|
||||||
{FormatWebP, "image/webp"},
|
{FormatWebP, testMIMEWebP},
|
||||||
{FormatGIF, "image/gif"},
|
{FormatGIF, testMIMEGIF},
|
||||||
{FormatAVIF, "image/avif"},
|
{FormatAVIF, testMIMEAVIF},
|
||||||
{FormatOriginal, "application/octet-stream"},
|
{FormatOriginal, mimeOctetStream},
|
||||||
{"unknown", "application/octet-stream"},
|
{"unknown", mimeOctetStream},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(string(tt.format), func(t *testing.T) {
|
t.Run(string(tt.format), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := ImageFormatToMIME(tt.format)
|
got := ImageFormatToMIME(tt.format)
|
||||||
|
|
||||||
if got != tt.wantMIME {
|
if got != tt.wantMIME {
|
||||||
@@ -335,19 +333,23 @@ func TestImageFormatToMIME(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeMIMEType(t *testing.T) {
|
func TestNormalizeMIMEType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"image/jpeg", "image/jpeg"},
|
{testMIMEJPEG, testMIMEJPEG},
|
||||||
{"IMAGE/JPEG", "image/jpeg"},
|
{"IMAGE/JPEG", testMIMEJPEG},
|
||||||
{"image/jpeg; charset=utf-8", "image/jpeg"},
|
{testMIMEJPEGParams, testMIMEJPEG},
|
||||||
{" image/jpeg ", "image/jpeg"},
|
{" image/jpeg ", testMIMEJPEG},
|
||||||
{"image/jpeg; boundary=something", "image/jpeg"},
|
{"image/jpeg; boundary=something", testMIMEJPEG},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := normalizeMIMEType(tt.input)
|
got := normalizeMIMEType(tt.input)
|
||||||
|
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
@@ -358,6 +360,8 @@ func TestNormalizeMIMEType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectSVG(t *testing.T) {
|
func TestDetectSVG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data string
|
data string
|
||||||
@@ -365,17 +369,24 @@ func TestDetectSVG(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
|
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
|
||||||
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
|
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
|
||||||
{"doctype", `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`, true},
|
{
|
||||||
|
"doctype",
|
||||||
|
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
|
||||||
|
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`,
|
||||||
|
true,
|
||||||
|
},
|
||||||
{"with whitespace", `
|
{"with whitespace", `
|
||||||
<?xml version="1.0"?><svg></svg>`, true},
|
<?xml version="1.0"?><svg></svg>`, true},
|
||||||
{"uppercase", `<SVG></SVG>`, true},
|
{"uppercase", `<SVG></SVG>`, true},
|
||||||
{"not svg", `<html></html>`, false},
|
{"not svg", `<html></html>`, false},
|
||||||
{"random text", `hello world`, false},
|
{"random text", `hello world`, false},
|
||||||
{"empty", ``, false},
|
{testNameEmpty, ``, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := detectSVG([]byte(tt.data))
|
got := detectSVG([]byte(tt.data))
|
||||||
|
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
@@ -386,6 +397,8 @@ func TestDetectSVG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSkipBOM(t *testing.T) {
|
func TestSkipBOM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
@@ -393,13 +406,15 @@ func TestSkipBOM(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
|
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
|
||||||
{"without BOM", []byte("hello"), []byte("hello")},
|
{"without BOM", []byte("hello"), []byte("hello")},
|
||||||
{"empty", []byte{}, []byte{}},
|
{testNameEmpty, []byte{}, []byte{}},
|
||||||
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
|
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
|
||||||
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
|
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := skipBOM(tt.data)
|
got := skipBOM(tt.data)
|
||||||
|
|
||||||
if !bytes.Equal(got, tt.want) {
|
if !bytes.Equal(got, tt.want) {
|
||||||
@@ -410,6 +425,8 @@ func TestSkipBOM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRealWorldSVGPatterns(t *testing.T) {
|
func TestRealWorldSVGPatterns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Test various real-world SVG patterns
|
// Test various real-world SVG patterns
|
||||||
svgPatterns := []string{
|
svgPatterns := []string{
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -419,7 +436,8 @@ func TestRealWorldSVGPatterns(t *testing.T) {
|
|||||||
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
</svg>`,
|
</svg>`,
|
||||||
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
|
||||||
|
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` + `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
</svg>`,
|
</svg>`,
|
||||||
}
|
}
|
||||||
@@ -445,6 +463,8 @@ func TestRealWorldSVGPatterns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// RIFF container but not WebP (e.g., WAV file)
|
// RIFF container but not WebP (e.g., WAV file)
|
||||||
wavData := []byte{
|
wavData := []byte{
|
||||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||||
@@ -453,12 +473,14 @@ func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := DetectFormat(wavData)
|
_, err := DetectFormat(wavData)
|
||||||
if err != ErrUnknownFormat {
|
if !errors.Is(err, ErrUnknownFormat) {
|
||||||
t.Errorf("DetectFormat(WAV) error = %v, want %v", err, ErrUnknownFormat)
|
t.Errorf("DetectFormat(WAV) error = %v, want %v", err, ErrUnknownFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectFormatFtypNotAVIF(t *testing.T) {
|
func TestDetectFormatFtypNotAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// ftyp container but not AVIF (e.g., MP4)
|
// ftyp container but not AVIF (e.g., MP4)
|
||||||
mp4Data := []byte{
|
mp4Data := []byte{
|
||||||
0x00, 0x00, 0x00, 0x1C, // box size
|
0x00, 0x00, 0x00, 0x1C, // box size
|
||||||
@@ -467,20 +489,24 @@ func TestDetectFormatFtypNotAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := DetectFormat(mp4Data)
|
_, err := DetectFormat(mp4Data)
|
||||||
if err != ErrUnknownFormat {
|
if !errors.Is(err, ErrUnknownFormat) {
|
||||||
t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat)
|
t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeekAndValidatePreservesReader(t *testing.T) {
|
func TestPeekAndValidatePreservesReader(t *testing.T) {
|
||||||
// Ensure that after PeekAndValidate, we can read the complete original content
|
t.Parallel()
|
||||||
|
|
||||||
|
// Ensure that after PeekAndValidate, we can read the complete
|
||||||
|
// original content
|
||||||
originalContent := append(
|
originalContent := append(
|
||||||
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D},
|
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D},
|
||||||
[]byte(strings.Repeat("PNG IDAT chunk data here ", 100))...,
|
[]byte(strings.Repeat("PNG IDAT chunk data here ", 100))...,
|
||||||
)
|
)
|
||||||
|
|
||||||
r := bytes.NewReader(originalContent)
|
r := bytes.NewReader(originalContent)
|
||||||
validated, err := PeekAndValidate(r, "image/png")
|
|
||||||
|
validated, err := PeekAndValidate(r, testMIMEPNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PeekAndValidate() error = %v", err)
|
t.Fatalf("PeekAndValidate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -492,6 +518,9 @@ func TestPeekAndValidatePreservesReader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !bytes.Equal(got, originalContent) {
|
if !bytes.Equal(got, originalContent) {
|
||||||
t.Errorf("Content mismatch: got %d bytes, want %d bytes", len(got), len(originalContent))
|
t.Errorf(
|
||||||
|
"Content mismatch: got %d bytes, want %d bytes",
|
||||||
|
len(got), len(originalContent),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testForwardedClient is the client address the proxy forwards.
|
||||||
|
const testForwardedClient = "203.0.113.7"
|
||||||
|
|
||||||
|
// newTestMiddleware builds a Middleware whose resolver trusts the given
|
||||||
|
// CIDRs and whose logger writes JSON to buf.
|
||||||
|
func newTestMiddleware(t *testing.T, buf *bytes.Buffer, trusted ...string) *Middleware {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(trusted))
|
||||||
|
|
||||||
|
for _, c := range trusted {
|
||||||
|
p, err := netip.ParsePrefix(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes = append(prefixes, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Middleware{
|
||||||
|
log: slog.New(slog.NewJSONHandler(buf, nil)),
|
||||||
|
config: &config.Config{TrustedProxies: prefixes},
|
||||||
|
clientIP: clientip.NewResolver(prefixes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientIPMiddlewareStoresResolvedIP verifies the ClientIP middleware
|
||||||
|
// puts the resolved address into the request context for a trusted and an
|
||||||
|
// untrusted peer.
|
||||||
|
func TestClientIPMiddlewareStoresResolvedIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
forwarded string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "trusted peer honors forwarded client",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
forwarded: testForwardedClient,
|
||||||
|
want: testForwardedClient,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "untrusted peer ignores forwarded header",
|
||||||
|
remoteAddr: "198.51.100.9:5000",
|
||||||
|
forwarded: testForwardedClient,
|
||||||
|
want: "198.51.100.9",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newTestMiddleware(t, &bytes.Buffer{}, "10.0.0.0/8")
|
||||||
|
|
||||||
|
var got string
|
||||||
|
|
||||||
|
handler := mw.ClientIP()(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
got = clientip.FromContext(r.Context())
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = tt.remoteAddr
|
||||||
|
req.Header.Set("X-Forwarded-For", tt.forwarded)
|
||||||
|
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("client IP in context = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingUsesResolvedClientIP verifies the logging middleware records
|
||||||
|
// the resolved forwarded client IP rather than the proxy peer address.
|
||||||
|
func TestLoggingUsesResolvedClientIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
mw := newTestMiddleware(t, &buf, "10.0.0.0/8")
|
||||||
|
|
||||||
|
handler := mw.ClientIP()(mw.Logging()(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:5000"
|
||||||
|
req.Header.Set("X-Forwarded-For", testForwardedClient)
|
||||||
|
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), `"remoteIP":"`+testForwardedClient+`"`) {
|
||||||
|
t.Errorf("log output missing resolved client IP; got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,19 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -21,9 +23,37 @@ import (
|
|||||||
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
|
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
|
||||||
const CORSMaxAgeSeconds = 86400
|
const CORSMaxAgeSeconds = 86400
|
||||||
|
|
||||||
|
// HSTSValue is the Strict-Transport-Security header value: one year with
|
||||||
|
// includeSubDomains. Emitted unconditionally even though pixa listens plain
|
||||||
|
// HTTP behind a TLS-terminating proxy; browsers ignore an HSTS header received
|
||||||
|
// over plaintext (RFC 6797 section 8.1), so it never lies about the connection,
|
||||||
|
// and emitting it here avoids trusting a forwarded-proto header.
|
||||||
|
const HSTSValue = "max-age=31536000; includeSubDomains"
|
||||||
|
|
||||||
|
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
||||||
|
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
||||||
|
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
|
||||||
|
// because the served templates carry inline onclick handlers (generator page)
|
||||||
|
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
|
||||||
|
// needs template changes outside this issue's scope.
|
||||||
|
const ContentSecurityPolicyValue = "default-src 'self'; " +
|
||||||
|
"script-src 'self' 'unsafe-inline'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self'; " +
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
|
||||||
|
// PermissionsPolicyValue is the Permissions-Policy header value. Every listed
|
||||||
|
// feature is denied because pixa uses none of them.
|
||||||
|
const PermissionsPolicyValue = "accelerometer=(), autoplay=(), camera=(), " +
|
||||||
|
"display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), " +
|
||||||
|
"microphone=(), payment=(), usb=()"
|
||||||
|
|
||||||
// Params defines dependencies for Middleware.
|
// Params defines dependencies for Middleware.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
}
|
}
|
||||||
@@ -32,6 +62,7 @@ type Params struct {
|
|||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
clientIP *clientip.Resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Middleware instance.
|
// New creates a new Middleware instance.
|
||||||
@@ -39,25 +70,54 @@ func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
|
|||||||
s := &Middleware{
|
s := &Middleware{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
clientIP: clientip.NewResolver(params.Config.TrustedProxies),
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipFromHostPort(hp string) string {
|
// ClientIP returns a middleware that resolves the real client IP,
|
||||||
h, _, err := net.SplitHostPort(hp)
|
// honoring X-Forwarded-For only from trusted proxies, and stores it in
|
||||||
if err != nil {
|
// the request context for the logging middleware and handlers to read.
|
||||||
return ""
|
func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := s.clientIP.Resolve(
|
||||||
|
r.RemoteAddr, r.Header.Values(clientip.ForwardedForHeader))
|
||||||
|
ctx := clientip.WithClientIP(r.Context(), ip)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if len(h) > 0 && h[0] == '[' {
|
|
||||||
return h[1 : len(h)-1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return h
|
// RateLimit returns a middleware that limits each client to requestLimit
|
||||||
|
// requests per window and refuses a request over the limit with 429 Too Many
|
||||||
|
// Requests and a Retry-After header. Clients are told apart by the address
|
||||||
|
// the ClientIP middleware stored in the request context, so ClientIP must
|
||||||
|
// run first. An IPv6 client is counted by its /64, which one client usually
|
||||||
|
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
||||||
|
// IPv4 address it carries, since every such address falls in the same /64.
|
||||||
|
// Counts are kept only for the current and the previous window, so memory
|
||||||
|
// stays bounded.
|
||||||
|
func (s *Middleware) RateLimit(
|
||||||
|
requestLimit int, window time.Duration,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
|
return httprate.LimitBy(requestLimit, window,
|
||||||
|
func(r *http.Request) (string, error) {
|
||||||
|
ip := clientip.FromContext(r.Context())
|
||||||
|
|
||||||
|
addr, err := netip.ParseAddr(ip)
|
||||||
|
if err == nil {
|
||||||
|
ip = addr.Unmap().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return httprate.CanonicalizeIP(ip), nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
|
|
||||||
statusCode int
|
statusCode int
|
||||||
bytesWritten int64
|
bytesWritten int64
|
||||||
}
|
}
|
||||||
@@ -85,6 +145,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
lrw := newLoggingResponseWriter(w)
|
lrw := newLoggingResponseWriter(w)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
||||||
@@ -96,7 +157,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
"request_id", reqID,
|
"request_id", reqID,
|
||||||
"referer", r.Referer(),
|
"referer", r.Referer(),
|
||||||
"proto", r.Proto,
|
"proto", r.Proto,
|
||||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
"remoteIP", clientip.FromContext(ctx),
|
||||||
"status", lrw.statusCode,
|
"status", lrw.statusCode,
|
||||||
"response_bytes", lrw.bytesWritten,
|
"response_bytes", lrw.bytesWritten,
|
||||||
"latency_ms", latency.Milliseconds(),
|
"latency_ms", latency.Milliseconds(),
|
||||||
@@ -111,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"},
|
||||||
@@ -160,6 +221,16 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|||||||
// Disable XSS filtering (modern browsers don't need it, can cause issues)
|
// Disable XSS filtering (modern browsers don't need it, can cause issues)
|
||||||
w.Header().Set("X-XSS-Protection", "0")
|
w.Header().Set("X-XSS-Protection", "0")
|
||||||
|
|
||||||
|
// Force HTTPS on future visits (ignored by browsers over plaintext)
|
||||||
|
w.Header().Set("Strict-Transport-Security", HSTSValue)
|
||||||
|
|
||||||
|
// Restrict content sources; frame-ancestors is the primary
|
||||||
|
// clickjacking control, X-Frame-Options the legacy fallback
|
||||||
|
w.Header().Set("Content-Security-Policy", ContentSecurityPolicyValue)
|
||||||
|
|
||||||
|
// Deny browser features pixa does not use
|
||||||
|
w.Header().Set("Permissions-Policy", PermissionsPolicyValue)
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create middleware instance
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mw := &Middleware{
|
||||||
|
log: slog.Default(),
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test handler
|
||||||
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap with security headers middleware
|
||||||
|
handler := mw.SecurityHeaders()(testHandler)
|
||||||
|
|
||||||
|
// Make a test request
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// Check security headers
|
||||||
|
tests := []struct {
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"X-Content-Type-Options", "nosniff"},
|
||||||
|
{"X-Frame-Options", "DENY"},
|
||||||
|
{"Referrer-Policy", "strict-origin-when-cross-origin"},
|
||||||
|
{"X-XSS-Protection", "0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.header, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := rec.Header().Get(tt.header)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mw := &Middleware{
|
||||||
|
log: slog.Default(),
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := mw.SecurityHeaders()(testHandler)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
|
||||||
|
{
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; " +
|
||||||
|
"script-src 'self' 'unsafe-inline'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self'; " +
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Permissions-Policy",
|
||||||
|
"accelerometer=(), autoplay=(), camera=(), " +
|
||||||
|
"display-capture=(), geolocation=(), gyroscope=(), " +
|
||||||
|
"magnetometer=(), microphone=(), payment=(), usb=()",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.header, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := rec.Header().Get(tt.header)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mw := &Middleware{
|
||||||
|
log: slog.Default(),
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler that sets its own headers
|
||||||
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("X-Custom-Header", "custom-value")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := mw.SecurityHeaders()(testHandler)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// Security headers should be present
|
||||||
|
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||||
|
t.Error("X-Content-Type-Options not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom headers should still be there
|
||||||
|
if rec.Header().Get("X-Custom-Header") != "custom-value" {
|
||||||
|
t.Error("Custom header was overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Header().Get("Content-Type") != "application/json" {
|
||||||
|
t.Error("Content-Type was overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSecurityHeaders(t *testing.T) {
|
|
||||||
// Create middleware instance
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mw := &Middleware{
|
|
||||||
log: slog.Default(),
|
|
||||||
config: cfg,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test handler
|
|
||||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wrap with security headers middleware
|
|
||||||
handler := mw.SecurityHeaders()(testHandler)
|
|
||||||
|
|
||||||
// Make a test request
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
// Check security headers
|
|
||||||
tests := []struct {
|
|
||||||
header string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"X-Content-Type-Options", "nosniff"},
|
|
||||||
{"X-Frame-Options", "DENY"},
|
|
||||||
{"Referrer-Policy", "strict-origin-when-cross-origin"},
|
|
||||||
{"X-XSS-Protection", "0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.header, func(t *testing.T) {
|
|
||||||
got := rec.Header().Get(tt.header)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mw := &Middleware{
|
|
||||||
log: slog.Default(),
|
|
||||||
config: cfg,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler that sets its own headers
|
|
||||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("X-Custom-Header", "custom-value")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler := mw.SecurityHeaders()(testHandler)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
// Security headers should be present
|
|
||||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
|
||||||
t.Error("X-Content-Type-Options not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom headers should still be there
|
|
||||||
if rec.Header().Get("X-Custom-Header") != "custom-value" {
|
|
||||||
t.Error("Custom header was overwritten")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rec.Header().Get("Content-Type") != "application/json" {
|
|
||||||
t.Error("Content-Type was overwritten")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,8 @@ func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
|
|||||||
|
|
||||||
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
|
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
|
||||||
|
|
||||||
if _, err := io.ReadFull(hkdfReader, key[:]); err != nil {
|
_, err := io.ReadFull(hkdfReader, key[:])
|
||||||
|
if err != nil {
|
||||||
return key, ErrKeyDerivation
|
return key, ErrKeyDerivation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,9 @@ func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
|
|||||||
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
|
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
|
||||||
// Generate random nonce
|
// Generate random nonce
|
||||||
var nonce [NonceSize]byte
|
var nonce [NonceSize]byte
|
||||||
if _, err := rand.Read(nonce[:]); err != nil {
|
|
||||||
|
_, err := rand.Read(nonce[:])
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
package seal
|
package seal_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeriveKey_Consistent(t *testing.T) {
|
func TestDeriveKey_Consistent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
masterKey := []byte("test-master-key-12345")
|
masterKey := []byte("test-master-key-12345")
|
||||||
salt := "test-salt-v1"
|
salt := "test-salt-v1"
|
||||||
|
|
||||||
key1, err := DeriveKey(masterKey, salt)
|
key1, err := seal.DeriveKey(masterKey, salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := DeriveKey(masterKey, salt)
|
key2, err := seal.DeriveKey(masterKey, salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,14 +30,16 @@ func TestDeriveKey_Consistent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeriveKey_DifferentSalts(t *testing.T) {
|
func TestDeriveKey_DifferentSalts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
masterKey := []byte("test-master-key-12345")
|
masterKey := []byte("test-master-key-12345")
|
||||||
|
|
||||||
key1, err := DeriveKey(masterKey, "salt-1")
|
key1, err := seal.DeriveKey(masterKey, "salt-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := DeriveKey(masterKey, "salt-2")
|
key2, err := seal.DeriveKey(masterKey, "salt-2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -43,14 +50,16 @@ func TestDeriveKey_DifferentSalts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
|
func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
salt := "test-salt"
|
salt := "test-salt"
|
||||||
|
|
||||||
key1, err := DeriveKey([]byte("master-key-1"), salt)
|
key1, err := seal.DeriveKey([]byte("master-key-1"), salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := DeriveKey([]byte("master-key-2"), salt)
|
key2, err := seal.DeriveKey([]byte("master-key-2"), salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,19 +70,21 @@ func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
||||||
key, err := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plaintext := []byte("hello, world! this is a test message.")
|
plaintext := []byte("hello, world! this is a test message.")
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key, plaintext)
|
ciphertext, err := seal.Encrypt(key, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypted, err := Decrypt(key, ciphertext)
|
decrypted, err := seal.Decrypt(key, ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decrypt() error = %v", err)
|
t.Fatalf("Decrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -84,15 +95,17 @@ func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
plaintext := []byte{}
|
plaintext := []byte{}
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key, plaintext)
|
ciphertext, err := seal.Encrypt(key, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypted, err := Decrypt(key, ciphertext)
|
decrypted, err := seal.Decrypt(key, ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decrypt() error = %v", err)
|
t.Fatalf("Decrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,31 +116,35 @@ func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_WrongKey(t *testing.T) {
|
func TestDecrypt_WrongKey(t *testing.T) {
|
||||||
key1, _ := DeriveKey([]byte("key-1"), "salt")
|
t.Parallel()
|
||||||
key2, _ := DeriveKey([]byte("key-2"), "salt")
|
|
||||||
|
key1, _ := seal.DeriveKey([]byte("key-1"), "salt")
|
||||||
|
key2, _ := seal.DeriveKey([]byte("key-2"), "salt")
|
||||||
|
|
||||||
plaintext := []byte("secret message")
|
plaintext := []byte("secret message")
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key1, plaintext)
|
ciphertext, err := seal.Encrypt(key1, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = Decrypt(key2, ciphertext)
|
_, err = seal.Decrypt(key2, ciphertext)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with wrong key")
|
t.Error("Decrypt() should fail with wrong key")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrDecryptionFailed {
|
if !errors.Is(err, seal.ErrDecryptionFailed) {
|
||||||
t.Errorf("Decrypt() error = %v, want %v", err, ErrDecryptionFailed)
|
t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrDecryptionFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_TamperedCiphertext(t *testing.T) {
|
func TestDecrypt_TamperedCiphertext(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
plaintext := []byte("secret message")
|
plaintext := []byte("secret message")
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key, plaintext)
|
ciphertext, err := seal.Encrypt(key, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -138,45 +155,51 @@ func TestDecrypt_TamperedCiphertext(t *testing.T) {
|
|||||||
tampered[10] ^= 0x01
|
tampered[10] ^= 0x01
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = Decrypt(key, string(tampered))
|
_, err = seal.Decrypt(key, string(tampered))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with tampered ciphertext")
|
t.Error("Decrypt() should fail with tampered ciphertext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_InvalidBase64(t *testing.T) {
|
func TestDecrypt_InvalidBase64(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
_, err := Decrypt(key, "not-valid-base64!!!")
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
|
|
||||||
|
_, err := seal.Decrypt(key, "not-valid-base64!!!")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with invalid base64")
|
t.Error("Decrypt() should fail with invalid base64")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInvalidPayload {
|
if !errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
|
t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrInvalidPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_TooShort(t *testing.T) {
|
func TestDecrypt_TooShort(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
|
|
||||||
// Create a base64 string that's too short to contain nonce + auth tag
|
// Create a base64 string that's too short to contain nonce + auth tag
|
||||||
_, err := Decrypt(key, "dG9vLXNob3J0")
|
_, err := seal.Decrypt(key, "dG9vLXNob3J0")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with too-short ciphertext")
|
t.Error("Decrypt() should fail with too-short ciphertext")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInvalidPayload {
|
if !errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
|
t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrInvalidPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
|
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
plaintext := []byte("same message")
|
plaintext := []byte("same message")
|
||||||
|
|
||||||
ciphertext1, _ := Encrypt(key, plaintext)
|
ciphertext1, _ := seal.Encrypt(key, plaintext)
|
||||||
ciphertext2, _ := Encrypt(key, plaintext)
|
ciphertext2, _ := seal.Encrypt(key, plaintext)
|
||||||
|
|
||||||
if ciphertext1 == ciphertext2 {
|
if ciphertext1 == ciphertext2 {
|
||||||
t.Error("Encrypt() should produce different ciphertexts due to random nonce")
|
t.Error("Encrypt() should produce different ciphertexts due to random nonce")
|
||||||
|
|||||||
+27
-8
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,25 +10,43 @@ import (
|
|||||||
// HTTP server configuration constants.
|
// HTTP server configuration constants.
|
||||||
const (
|
const (
|
||||||
HTTPReadTimeout = 30 * time.Second
|
HTTPReadTimeout = 30 * time.Second
|
||||||
HTTPWriteTimeout = 60 * time.Second
|
// HTTPReadHeaderTimeout bounds the request-header read on its own,
|
||||||
|
// short, so a slowloris client dribbling headers is dropped well
|
||||||
|
// before it ties up a connection for the whole ReadTimeout window.
|
||||||
|
HTTPReadHeaderTimeout = 10 * time.Second
|
||||||
|
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
|
||||||
|
// held open, so idle connections cannot accumulate without limit on a
|
||||||
|
// service targeting high concurrency.
|
||||||
|
HTTPIdleTimeout = 120 * time.Second
|
||||||
HTTPMaxHeaderBytes = 8 << 10 // 8KB
|
HTTPMaxHeaderBytes = 8 << 10 // 8KB
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) serveUntilShutdown() {
|
// newHTTPServer builds the http.Server with the hardening timeouts and
|
||||||
listenAddr := fmt.Sprintf(":%d", s.config.Port)
|
// limits applied. It is separate from serveUntilShutdown so the
|
||||||
s.httpServer = &http.Server{
|
// configuration can be asserted in a test without binding a listener.
|
||||||
Addr: listenAddr,
|
func (s *Server) newHTTPServer() *http.Server {
|
||||||
|
return &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.config.Port),
|
||||||
ReadTimeout: HTTPReadTimeout,
|
ReadTimeout: HTTPReadTimeout,
|
||||||
WriteTimeout: HTTPWriteTimeout,
|
ReadHeaderTimeout: HTTPReadHeaderTimeout,
|
||||||
|
WriteTimeout: s.config.DownstreamTimeout,
|
||||||
|
IdleTimeout: HTTPIdleTimeout,
|
||||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveUntilShutdown() {
|
||||||
|
s.httpServer = s.newHTTPServer()
|
||||||
|
|
||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|
||||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
|
err := s.httpServer.ListenAndServe()
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
s.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
if s.cancelFunc != nil {
|
||||||
s.cancelFunc()
|
s.cancelFunc()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewHTTPServerTimeouts verifies that the constructed http.Server
|
||||||
|
// carries every hardening timeout wired onto it, including the slowloris
|
||||||
|
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
|
||||||
|
// guards against a field being defined but never set on the server, so
|
||||||
|
// each assertion compares the server field to its constant, or, for
|
||||||
|
// WriteTimeout, to downstream_timeout from the config.
|
||||||
|
func TestNewHTTPServerTimeouts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := &Server{config: &config.Config{
|
||||||
|
Port: 8080,
|
||||||
|
DownstreamTimeout: 45 * time.Second,
|
||||||
|
}}
|
||||||
|
|
||||||
|
srv := s.newHTTPServer()
|
||||||
|
|
||||||
|
fields := []struct {
|
||||||
|
name string
|
||||||
|
got time.Duration
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
|
||||||
|
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
|
||||||
|
{"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
|
||||||
|
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.got != f.want {
|
||||||
|
t.Errorf("%s = %v, want %v", f.name, f.got, f.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.MaxHeaderBytes != HTTPMaxHeaderBytes {
|
||||||
|
t.Errorf("MaxHeaderBytes = %d, want %d",
|
||||||
|
srv.MaxHeaderBytes, HTTPMaxHeaderBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.Handler != s {
|
||||||
|
t.Error("Handler is not the server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHardeningTimeoutValues pins the intent behind the two new timeouts
|
||||||
|
// without hard-coding brittle exact durations: the header-read phase is
|
||||||
|
// bounded strictly shorter than the whole-request read (the slowloris
|
||||||
|
// dribble), and idle keep-alive connections are bounded rather than held
|
||||||
|
// open forever.
|
||||||
|
func TestHardeningTimeoutValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if HTTPReadHeaderTimeout <= 0 || HTTPReadHeaderTimeout > HTTPReadTimeout {
|
||||||
|
t.Errorf("ReadHeaderTimeout = %v, want positive and <= ReadTimeout %v",
|
||||||
|
HTTPReadHeaderTimeout, HTTPReadTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if HTTPIdleTimeout <= 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want positive bound", HTTPIdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/handlers"
|
||||||
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSigningKey is a throwaway signing key; submitting it logs in.
|
||||||
|
const testSigningKey = "test-signing-key-0123456789abcdef"
|
||||||
|
|
||||||
|
// wrongKey is submitted for a failed login.
|
||||||
|
const wrongKey = "not-the-signing-key"
|
||||||
|
|
||||||
|
// Addresses for the login rate limit tests. The test server trusts
|
||||||
|
// 10.0.0.0/8 as its proxies, so the X-Forwarded-For sent by proxyPeer is
|
||||||
|
// believed and the one sent by firstClient or secondClient is ignored.
|
||||||
|
const (
|
||||||
|
firstClient = "198.51.100.1:40000"
|
||||||
|
secondClient = "198.51.100.2:40000"
|
||||||
|
proxyPeer = "10.0.0.1:40000"
|
||||||
|
firstForwarded = "203.0.113.1"
|
||||||
|
secondForwarded = "203.0.113.2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// csrfFieldPattern extracts the CSRF token rendered into the login form.
|
||||||
|
var csrfFieldPattern = regexp.MustCompile(
|
||||||
|
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
||||||
|
|
||||||
|
// newTestServer builds the server's real routes from the constructors
|
||||||
|
// cmd/pixad uses, with a throwaway state directory. Debug marks requests
|
||||||
|
// as plain HTTP, so the CSRF check runs without an https Referer.
|
||||||
|
func newTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Debug: true,
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
StateDir: stateDir,
|
||||||
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
DownstreamTimeout: config.DefaultDownstreamTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("database.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hc, err := healthcheck.New(lc, healthcheck.Params{
|
||||||
|
Globals: &globals.Globals{}, Config: cfg, Logger: log, Database: db,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("healthcheck.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := handlers.New(lc, handlers.Params{
|
||||||
|
Logger: log, Healthcheck: hc, Database: db, Config: cfg,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handlers.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mw, err := middleware.New(lc, middleware.Params{Logger: log, Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("middleware.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
t.Cleanup(lc.RequireStop)
|
||||||
|
|
||||||
|
s := &Server{config: cfg, mw: mw, h: h}
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientRequest builds a request for / arriving from remoteAddr, carrying
|
||||||
|
// forwardedFor as its X-Forwarded-For header when that is not empty.
|
||||||
|
func clientRequest(
|
||||||
|
t *testing.T, method string, body io.Reader, remoteAddr, forwardedFor string,
|
||||||
|
) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||||
|
req.RemoteAddr = remoteAddr
|
||||||
|
|
||||||
|
if forwardedFor != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", forwardedFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// postLogin loads the login form with GET / and submits key in it with
|
||||||
|
// POST /, as a browser does, both from the same client. GET / is not rate
|
||||||
|
// limited, so the form must load even for a client over the limit.
|
||||||
|
func postLogin(
|
||||||
|
t *testing.T, s *Server, remoteAddr, forwardedFor, key string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
page := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(page,
|
||||||
|
clientRequest(t, http.MethodGet, nil, remoteAddr, forwardedFor))
|
||||||
|
|
||||||
|
if page.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET / status = %d, want %d", page.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := csrfFieldPattern.FindStringSubmatch(page.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("no CSRF token field found in the login form")
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{"key": {key}, "gorilla.csrf.Token": {match[1]}}
|
||||||
|
req := clientRequest(t, http.MethodPost,
|
||||||
|
strings.NewReader(form.Encode()), remoteAddr, forwardedFor)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
for _, c := range page.Result().Cookies() {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// tripLoginRateLimit makes LoginAttemptsPerMinute failed logins from one
|
||||||
|
// client, each answered with the login form again, then one more, which
|
||||||
|
// must be refused with 429. It returns the response to that last attempt.
|
||||||
|
func tripLoginRateLimit(
|
||||||
|
t *testing.T, s *Server, remoteAddr, forwardedFor string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for attempt := range LoginAttemptsPerMinute {
|
||||||
|
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("failed login %d status = %d, want %d",
|
||||||
|
attempt+1, rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("login over the limit status = %d, want %d",
|
||||||
|
rec.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRateLimitRefusesAttemptOverLimit verifies the login attempt
|
||||||
|
// after LoginAttemptsPerMinute failed ones from one client is refused with
|
||||||
|
// 429 and a Retry-After header, and that the client cannot get around the
|
||||||
|
// limit by sending X-Forwarded-For: from a peer that is not a trusted
|
||||||
|
// proxy, the header is ignored.
|
||||||
|
func TestLoginRateLimitRefusesAttemptOverLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
|
||||||
|
rec := tripLoginRateLimit(t, s, firstClient, "")
|
||||||
|
|
||||||
|
retryAfter := rec.Header().Get("Retry-After")
|
||||||
|
|
||||||
|
seconds, err := strconv.Atoi(retryAfter)
|
||||||
|
if err != nil || seconds <= 0 {
|
||||||
|
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||||
|
retryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = postLogin(t, s, firstClient, secondForwarded, wrongKey)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("login with X-Forwarded-For from an untrusted peer "+
|
||||||
|
"status = %d, want %d", rec.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRateLimitLeavesOtherClientsAlone verifies one client going over
|
||||||
|
// the limit does not limit another: a failed login from a different
|
||||||
|
// address is answered with the login form, and the signing key still logs
|
||||||
|
// it in.
|
||||||
|
func TestLoginRateLimitLeavesOtherClientsAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
|
||||||
|
tripLoginRateLimit(t, s, firstClient, "")
|
||||||
|
|
||||||
|
rec := postLogin(t, s, secondClient, "", wrongKey)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("failed login from another client status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = postLogin(t, s, secondClient, "", testSigningKey)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("login with the signing key from another client "+
|
||||||
|
"status = %d, want %d", rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRateLimitCountsClientsBehindProxySeparately verifies the limit
|
||||||
|
// counts the client address resolved from X-Forwarded-For, not the address
|
||||||
|
// of the trusted proxy the requests arrive from, so two clients behind the
|
||||||
|
// same proxy are counted separately.
|
||||||
|
func TestLoginRateLimitCountsClientsBehindProxySeparately(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
|
||||||
|
tripLoginRateLimit(t, s, proxyPeer, firstForwarded)
|
||||||
|
|
||||||
|
rec := postLogin(t, s, proxyPeer, secondForwarded, wrongKey)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("failed login from a second client behind the proxy "+
|
||||||
|
"status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRateLimitCountsIPv6ClientsByPrefix verifies an IPv6 client is
|
||||||
|
// counted by its /64: another address in the same /64 is refused too,
|
||||||
|
// while an address in a different /64 is not.
|
||||||
|
func TestLoginRateLimitCountsIPv6ClientsByPrefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
|
||||||
|
tripLoginRateLimit(t, s, proxyPeer, "2001:db8::1")
|
||||||
|
|
||||||
|
rec := postLogin(t, s, proxyPeer, "2001:db8::2", wrongKey)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("login from the same /64 status = %d, want %d",
|
||||||
|
rec.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = postLogin(t, s, proxyPeer, "2001:db8:0:1::1", wrongKey)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("login from another /64 status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRateLimitCountsIPv4MappedClientsSeparately verifies an IPv4
|
||||||
|
// client that the proxy forwards in IPv4-mapped IPv6 form (::ffff:a.b.c.d)
|
||||||
|
// is counted by its IPv4 address, not by the /64 that every such address
|
||||||
|
// shares, so two of them behind the proxy are counted separately.
|
||||||
|
func TestLoginRateLimitCountsIPv4MappedClientsSeparately(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
|
||||||
|
tripLoginRateLimit(t, s, proxyPeer, "::ffff:"+firstForwarded)
|
||||||
|
|
||||||
|
rec := postLogin(t, s, proxyPeer, "::ffff:"+secondForwarded, wrongKey)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("failed login from a second IPv4-mapped client "+
|
||||||
|
"status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user