Author SHA1 Message Date
clawbot 1b60264cf2 Make the periodic reconciliation test wait for the startup pass (closes #189)
check / check (push) Failing after 3s
TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup slept for
three eviction intervals before writing its file, so on a slow start the
startup pass could still be running and adopt the file itself, and the
test passed without a periodic pass. It now holds the test database's
only connection until the startup pass waits for it, writes the file and
lets the connection go, as TestEvictionRunsOnPeriodicSchedule does, so
only a periodic reconciliation pass can adopt the file. Test only.

Model: opus-5-5
2026-10-04 16:39:15 +00:00
137 changed files with 3437 additions and 12483 deletions
+7 -77
View File
@@ -1,82 +1,12 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.git/config
.gitignore
.DS_Store
.env*
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# pixa's own entries. On the host, `make build` writes bin/pixad, and the
# example config keeps its state directory in data/.
/bin
/data
# Local config files, kept out of git because they can hold the signing key.
**/[cC][oO][nN][fF][iI][gG].[yY][mM][lL]
**/[cC][oO][nN][fF][iI][gG].[yY][aA][mM][lL]
**/[cC][oO][nN][fF][iI][gG].[dD][eE][vV].[yY][mM][lL]
node_modules
bin/
data/
-3
View File
@@ -13,6 +13,3 @@ indent_style = tab
[*.go]
indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
-11
View File
@@ -1,21 +1,10 @@
name: check
on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild
- run: script/docker-smoke
+6 -44
View File
@@ -11,52 +11,15 @@ Thumbs.db
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation at the end of this file, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# Go
*.log
*.out
*.test
# Environment / secrets
.env
.env.*
*.pem
*.key
# Dependencies
vendor/
node_modules/
# Build output
/bin/
@@ -68,6 +31,5 @@ vendor/
*.sqlite3
# Local dev configs
config.yml
config.yaml
config.dev.yml
-3
View File
@@ -17,7 +17,6 @@ linters:
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
@@ -25,8 +24,6 @@ linters:
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
# Misses findings at random in v2.14.0; back once a pinned release fixes it
- canonicalheader
settings:
lll:
line-length: 88
-2
View File
@@ -1,2 +0,0 @@
node_modules/
yarn.lock
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
-71
View File
@@ -1,71 +0,0 @@
# Repository Rules
Last Updated 2026-01-08
These rules MUST be followed at all times, it is very important.
- Never use `git add -A` - add specific changes to a deliberate commit. A commit
should contain one change. After each change, make a commit with a good
one-line summary.
- NEVER modify the linter config without asking first.
- NEVER modify tests to exclude special cases or otherwise get them to pass
without asking first. In almost all cases, the code should be changed, NOT the
tests. If you think the test needs to be changed, make your case for that and
ask for permission to proceed, then stop. You need explicit user approval to
modify existing tests. (You do not need user approval for writing NEW tests.)
- When linting, assume the linter config is CORRECT, and that each item output
by the linter is something that legitimately needs fixing in the code.
- When running tests, use `make test`.
- Before commits, run `make check`. This runs `make lint` and `make test` and
`make check-fmt`. Any issues discovered MUST be resolved before committing
unless explicitly told otherwise.
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate
logging to the test to ensure it is written correctly. Commit that. Then go
about fixing the bug until the test passes (without modifying the test
further). Then commit that.
- When adding a new feature, do the same - implement a test first (TDD). It
doesn't have to be super complex. Commit the test, then commit the feature.
- When adding a new feature, use a feature branch. When the feature is
completely finished and the code is up to standards (passes `make check`) then
and only then can the feature branch be merged into `main` and the branch
deleted.
- Write godoc documentation comments for all exported types and functions as you
go along.
- ALWAYS be consistent in naming. If you name something one thing in one place,
name it the EXACT SAME THING in another place.
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
- This is not prototype or teaching code - this is designed for production. Any
security issues (such as denial of service) or other web vulnerabilities are
P1 bugs and must be added to TODO.md at the top.
- As this is production code, no stubbing of implementations unless specifically
instructed. We need working implementations.
- NEVER silently fall back to a different setting when a user's parameter
explicitly specifies a value. If a user requests format=webp and WebP encoding
is not supported, return an error - do NOT silently output PNG instead. If a
user specifies fit=invalid and that fit mode doesn't exist, return an error -
do NOT silently default to "cover". Silent fallbacks violate the principle of
least surprise and mask bugs. The only acceptable defaults are for OMITTED
parameters, never for INVALID explicit values.
- Avoid vendoring deps unless specifically instructed to. NEVER commit the
vendor directory, NEVER commit compiled binaries. If these directories or
files exist, add them to .gitignore (and commit the .gitignore) if they are
not already in there. Keep the entire git repository (with history) small -
under 20MiB, unless you specifically must commit larger files (e.g. test
fixture example media files). Only OUR source code and immediately supporting
files (such as test examples) goes into the repo/history.
+76
View File
@@ -0,0 +1,76 @@
# Repository Rules
Last Updated 2026-01-08
These rules MUST be followed at all times, it is very important.
* Never use `git add -A` - add specific changes to a deliberate commit. A
commit should contain one change. After each change, make a commit with a
good one-line summary.
* NEVER modify the linter config without asking first.
* NEVER modify tests to exclude special cases or otherwise get them to pass
without asking first. In almost all cases, the code should be changed,
NOT the tests. If you think the test needs to be changed, make your case
for that and ask for permission to proceed, then stop. You need explicit
user approval to modify existing tests. (You do not need user approval
for writing NEW tests.)
* When linting, assume the linter config is CORRECT, and that each item
output by the linter is something that legitimately needs fixing in the
code.
* When running tests, use `make test`.
* Before commits, run `make check`. This runs `make lint` and `make test`
and `make check-fmt`. Any issues discovered MUST be resolved before
committing unless explicitly told otherwise.
* When fixing a bug, write a failing test for the bug FIRST. Add
appropriate logging to the test to ensure it is written correctly. Commit
that. Then go about fixing the bug until the test passes (without
modifying the test further). Then commit that.
* When adding a new feature, do the same - implement a test first (TDD). It
doesn't have to be super complex. Commit the test, then commit the
feature.
* When adding a new feature, use a feature branch. When the feature is
completely finished and the code is up to standards (passes `make check`)
then and only then can the feature branch be merged into `main` and the
branch deleted.
* Write godoc documentation comments for all exported types and functions as
you go along.
* ALWAYS be consistent in naming. If you name something one thing in one
place, name it the EXACT SAME THING in another place.
* Be descriptive and specific in naming. `wl` is bad;
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
`MaxConnectionsPerHost` is good.
* This is not prototype or teaching code - this is designed for production.
Any security issues (such as denial of service) or other web
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
* As this is production code, no stubbing of implementations unless
specifically instructed. We need working implementations.
* NEVER silently fall back to a different setting when a user's parameter
explicitly specifies a value. If a user requests format=webp and WebP
encoding is not supported, return an error - do NOT silently output PNG
instead. If a user specifies fit=invalid and that fit mode doesn't exist,
return an error - do NOT silently default to "cover". Silent fallbacks
violate the principle of least surprise and mask bugs. The only acceptable
defaults are for OMITTED parameters, never for INVALID explicit values.
* Avoid vendoring deps unless specifically instructed to. NEVER commit
the vendor directory, NEVER commit compiled binaries. If these
directories or files exist, add them to .gitignore (and commit the
.gitignore) if they are not already in there. Keep the entire git
repository (with history) small - under 20MiB, unless you specifically
must commit larger files (e.g. test fixture example media files). Only
OUR source code and immediately supporting files (such as test examples)
goes into the repo/history.
+1259
View File
File diff suppressed because it is too large Load Diff
+60 -94
View File
@@ -1,114 +1,80 @@
# Every stage declares ARG CACHEBUST first: script/ passes a new value
# on every build, which reruns every RUN step after it. The steps that
# compile mount Go's build cache, which survives from build to build.
# Lint phase. script/lint builds it alone. The linter is run directly:
# `make lint` and script/lint are themselves a docker build.
# golangci/golangci-lint:v2.14.0, 2026-10-15
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
ARG CACHEBUST
# The linter compiles every package, and govips needs the libvips
# headers for that. REPO_POLICIES.md has the lint phase install them
# itself; this image is Debian, so with apt-get rather than apk.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
golangci-lint run --config .golangci.yml ./...
# Test phase. script/test builds it alone. -race needs cgo and so a C
# compiler, which the Debian Go image ships and the alpine one does not.
# golang:1.25.4-trixie, 2026-10-15
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
ARG CACHEBUST
# Lint stage
# Same image as Dockerfile.lint: change both pins together.
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
WORKDIR /src
# script/bootstrap --cgo installs the build dependencies (the libvips
# and libheif headers, and libvips' JPEG XL support, which the tests
# need) and downloads the Go modules.
# 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 ./
RUN script/bootstrap --cgo
COPY . .
# Without -v first; on a failure, again with -v for the details, and
# the step fails even if the second run passes. -count=1 because the
# mounted cache also holds test results, which go test would otherwise
# replay. -parallel 4: by default a package runs as many of its tests at
# once as the host has CPUs, and on a busy host they then wait so long
# to be scheduled that a test that times a request can see it return a
# second late.
RUN --mount=type=cache,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
# Build stage. Nothing is wanted from the two phases above: these copies
# make BuildKit build them first, so this stage runs only when lint and
# test passed.
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src
# Build dependencies, git among them, and Go modules, as in the test
# phase
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap --cgo
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
RUN script/bootstrap
# Copy source code
COPY . .
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context: the
# tag on a tagged commit, tag-N-gHASH after one, the short commit when
# no tag is reachable. With .git present, a version that is still empty,
# dev or unknown fails the build: git is missing or could not read the
# checkout. A context without .git, as from a source tarball, stamps an
# empty version. CGO stays enabled for govips; -trimpath keeps build
# paths out of the binary, and -s -w leave out the symbol table and
# debug information.
# Tells script/lint it is inside a container, so it runs the linter.
ENV container=docker
# Run formatting check and linter. script/cibuild and script/docker pass
# a new CHECK_EPOCH on every run, and each check step names it in its
# command, so a new value reruns the step instead of reusing a cached
# success that checked nothing. A plain `docker build .` leaves it empty
# and reuses the check steps only for an identical build context.
ARG CHECK_EPOCH
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN echo "check epoch: ${CHECK_EPOCH}" && make lint
# Build stage
# golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
WORKDIR /src
# Build dependencies and Go modules, as in the lint stage
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap
# Copy source code
COPY . .
# Run tests; a new CHECK_EPOCH reruns them, as in the lint stage.
ARG CHECK_EPOCH
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# VERSION is declared here, not earlier: a new value reruns only the
# build, not script/bootstrap or the tests. Given none, the version is
# `git describe --tags --always` of the .git in the build context (git
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
# after one, the short commit when no tag is reachable. A context that
# carries .git and still yields no version fails the build; one without
# .git, as from a source tarball, stamps an empty version. 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
RUN --mount=type=cache,target=/root/.cache/go-build \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "the build context carries .git but yields no version" >&2; \
exit 1; \
fi; \
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags "-s -w -X main.Version=${version}" \
-o /pixad ./cmd/pixad
# Runtime stage, and the last one: a plain `docker build .` builds this
# stage and what it depends on, and nothing else. It must use the Alpine
# release the golang image above is based on, so that pixad runs against
# the libvips and musl it was built with.
# alpine:3.22, 2026-10-08
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
ARG CACHEBUST
# Runtime stage
# alpine:3.21, 2026-02-25
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
# support, without which pixad does not start.
# Install runtime dependencies only
RUN apk add --no-cache \
vips \
vips-jxl \
libheif \
ca-certificates \
tzdata \
+34
View File
@@ -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
+10 -15
View File
@@ -1,10 +1,10 @@
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest
.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")
LDFLAGS := -X main.Version=$(VERSION)
# Use nix-shell to provide CGO dependencies unless they are already available
# (e.g. inside an existing nix-shell).
# (e.g. inside a Docker build or an existing nix-shell).
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
ifdef HAS_PKGCONFIG
NIX_RUN_PREFIX =
@@ -32,11 +32,11 @@ fmt-check:
fmt:
@script/fmt
# Run linter (the lint phase of the Dockerfile)
# Run linter
lint:
@script/lint
# Run tests (the test phase of the Dockerfile)
# Run tests (30-second timeout)
test:
@script/test
@@ -59,25 +59,20 @@ docker:
docker-smoke:
@script/docker-smoke
# Measure throughput, latency and peak memory with the default duration and
# number of clients (needs Docker and Go; a benchmark, not part of check)
loadtest:
@script/loadtest
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
docker-versioned:
@script/docker
docker tag pixa pixa:$(VERSION)
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
# Run tests in Docker, as `make test` does
# Run tests in Docker (needed for CGO/libvips)
docker-test:
@script/test
docker build --target builder --build-arg VERSION=$(VERSION) -t pixad-builder .
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
# Run local dev server in Docker
devserver: docker-versioned devserver-stop
docker run -d --name pixad-dev -p 8080:8080 \
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
pixa:latest
pixad:latest
@echo "pixad running at http://localhost:8080"
# Stop dev server
+224 -530
View File
File diff suppressed because it is too large Load Diff
+88 -436
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-15
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -54,37 +54,21 @@ style conventions are in separate documents:
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node only when its major version is the pinned one; otherwise (node
missing, or another major, such as the node 24 the CI runner image ships) it
installs the PINNED node version via nvm and installs yarn and the packages
under it. nvm itself is installed first if missing, from a hash-verified
GitHub release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. Only the major version is compared
because the `Dockerfile` stages start from a node image whose exact version is
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**: when
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
under another node. The host entrypoints that need yarn — `script/fmt` and
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
pinned node. A runner carrying nothing but docker and git then gets through
`script/check`. Four further scripts are our own extensions to the standard:
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
`script/precommit` is what the git pre-commit hook runs, and it calls
`script/check`; `script/install-precommit` installs the git pre-commit hook
(the `make hooks` target shims to it); and `script/projectname` (literally
that filename) simply outputs the project's name. Scripts that need the name
call `script/projectname` — e.g. `script/docker` assembles its image tag from
it — so those scripts stay byte-identical across all repos. Repo-type-specific
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
@@ -105,257 +89,87 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else:
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
```sh
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target lint --output type=cacheonly .
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test --output type=cacheonly .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**The gate builds write no image.** With `--output type=cacheonly` the phase
runs and a failing step fails the build, but the result is not exported.
Nothing uses those images, and writing one out is slow: a Go test phase's
image holds the toolchain. A build given neither `--output` nor `-t` writes
an untagged image and leaves it dangling, on every developer host and every
CI runner. `script/cibuild` and `script/docker` build the image that ships
and tag it, so each build replaces the previous image; each assigns the tag
on its own line before the build, so `set -e` stops it where
`script/projectname` fails.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own golangci-lint cache, its
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the check `RUN` is served from cache, nothing executes, and the
build still exits 0. Every `docker build` in `script/` therefore passes
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
id, so no two builds share a value, not even the three in one
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
`script/docker` are the four, and there is no fifth — `script/check` runs the
two gate phases and `script/fmt-check`, and builds no image of its own. Every
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
are compared by content and need nothing. A stage without the `ARG` line is
served from cache on an unchanged tree, so a repository re-vendors the scripts
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
it reruns every step too, but it also empties every cache mount, and Go's
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
packages survive between check builds, would start empty every time. A bare
`docker build .` is not evidence that anything ran: a sub-second build
reporting success is a cache hit, not a result. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host. When a check is added or changed, prove it works by
planting a defect it must catch and watching the run fail on it, then revert
the defect. A green run alone shows neither that the check ran nor that it
covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Every stage declares ARG CACHEBUST first: script/ passes a new value
# on every build, which reruns every RUN step after it. The steps that
# compile mount Go's build cache, which survives from build to build.
# Lint phase
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
ARG CACHEBUST
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
golangci-lint run --config .golangci.yml ./...
RUN make fmt-check
RUN make lint
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not. -count=1 because the mounted
# cache also holds test results, which go test would otherwise replay.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
ARG CACHEBUST
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
# Runtime stage
FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the ordering edge. BuildKit runs stages in parallel by default,
and a stage nothing depends on is not built at all, so without these two
lines a red gate would not fail the build.
- Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
it for root), and only on the steps that compile: `golangci-lint run`,
both `go test` runs and `go build`. It keeps compiled packages between
check builds, and Go reuses one only when all its inputs are unchanged, so
it saves the compile without skipping a check. Nothing else is kept:
golangci-lint's own cache would replay stored findings in place of a lint
run, and Go builds from whatever files a kept module cache holds without
checking them again (only `go mod verify` does), so a file changed there
would go unnoticed. Go's build cache also holds test results, which is why
both `go test` runs carry `-count=1`.
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step,
with `persist-credentials: false`: `script/cibuild` needs no token, and
without it the checkout leaves the job's token in `.git/config` for every
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
tags `git describe` needs: by default it clones shallow with no tags, and a
tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
so a new push cancels the older run on the same branch, queued or running, and
no other: runs for replaced commits do not hold up the shared runner.
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
so a successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The
image build gets a `CACHEBUST` value of its own and so runs the gate phases a
second time. That is the price of the rule above, and it is worth paying: the
image that ships is built from a run of its own gates rather than from a cache
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
shared runner after 20 minutes. That allows for the three Docker builds
described above (the test phase, the lint phase, then the image), each held to
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
limited to `main` by a `branches` list under `on: push` cannot be checked by
review: to try a change to it, add the feature branch to that list and push,
then remove the branch from the list again before merging. Keep any job in it
that publishes behind `if: github.ref_name == 'main'`, so the run from the
feature branch publishes nothing.
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -375,21 +189,14 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
hard cap so that it catches a genuinely hung test rather than a merely slow
one.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **The test command should use the conditional verbose rerun pattern.** Run
tests without `-v` (verbose) first. If tests fail, automatically rerun with
`-v` to show full output. This keeps CI logs and `docker build` output clean
on success (just package/suite summaries) while providing full diagnostic
detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@@ -402,25 +209,11 @@ style conventions are in separate documents:
```makefile
test:
@go test -count=1 -timeout 90s -race -cover ./... || \
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
go test -timeout 30s -race -v ./...; exit 1; }
```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. The directory lasts from one run to the next on a
developer's machine, where this target runs, and in the `test` phase of the
`Dockerfile`, which mounts it to keep compiled packages between check
builds, so the phase's `go test` runs carry `-count=1` too. A new
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
Python example:
```makefile
@@ -446,89 +239,10 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
`node_modules/`, and the repo's own build outputs. Fetch the standard
`.gitignore` from
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
own entries, such as its binaries; a re-vendor replaces the standard part and
keeps those entries. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root, while `config/.env`
and `certs/server.key` still reach the context and can land in an image layer
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
also catches something the build needs, re-include it with a negation
(`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own artifacts.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo and another session's unreviewed work can
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` states in its own comment, since consuming repos receive the
file and not the tracker: the directory is created in the agent's working
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# The version and the tag each get their own line: a failing command
# substitution inside an argument does not trip `set -e`, so the inline
# form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
tag="$(script/projectname)"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$tag" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
than failing, and the `[ -n "$version" ]` line is the single place the
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so the canonical
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
size is not a substitute: a nested secret is a few bytes, and BuildKit
transfers only the delta from the previous build.
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
@@ -544,61 +258,9 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent: fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
byte-identical, so that no repo can quietly loosen its own linting. Linter
configuration changes are made to the canonical copy in the `prompts` repo and
reach consuming repos by re-vendoring; an agent may open a PR against
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
`PATH` only, so on an already-provisioned machine the pin is inert and a
version bump is a silent no-op — while the Dockerfile, installing into a clean
image, gets the pinned version, so a local `make check` and `make docker` can
disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Node is the exception to the whole-token comparison: the canonical
`script/bootstrap` compares only its major version, because the node 22
alpine image the `Dockerfile` stages start from is not the exact pin, and
nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
@@ -709,21 +371,15 @@ style conventions are in separate documents:
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings: the standard file from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
one for another language it uses. A re-vendor replaces the standard part and
keeps those sections.
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
@@ -750,7 +406,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+336 -796
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
// Command loadtest-origin is the upstream host script/loadtest points pixad
// at; internal/loadtestorigin says what it does.
package main
import "sneak.berlin/go/pixa/internal/loadtestorigin"
func main() {
loadtestorigin.Run()
}
+61 -2
View File
@@ -1,10 +1,69 @@
// Package main is the entry point for the pixad image proxy server.
package main
import "sneak.berlin/go/pixa/internal/app"
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"go.uber.org/fx"
"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"
"sneak.berlin/go/pixa/internal/server"
)
var Version string //nolint:gochecknoglobals // set by ldflags
var configPath string //nolint:gochecknoglobals // cobra flag
func main() {
app.Run(Version)
rootCmd := &cobra.Command{
Use: "pixad",
Short: "Pixa image caching proxy server",
Run: run,
}
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
err := rootCmd.Execute()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(_ *cobra.Command, _ []string) {
globals.Version = Version
// Set config path in environment if specified via flag
if configPath != "" {
_ = os.Setenv("PIXA_CONFIG_PATH", configPath)
}
// A write to a closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
fx.New(
fx.Provide(
config.New,
database.New,
globals.New,
handlers.New,
logger.New,
server.New,
middleware.New,
healthcheck.New,
),
fx.Invoke(
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
}
@@ -34,10 +34,9 @@ maintenance_mode: false
state_dir: ./data
# SQLite database URL (default:
# file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds
# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the
# key out to use the default.
# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)"
# file:<state_dir>/state.sqlite3?_journal_mode=WAL). An empty value aborts
# startup; leave the key out to use the default.
# db_url: "file:./data/state.sqlite3?_journal_mode=WAL"
# Image proxy settings
# HMAC signing key for URL signatures (required, at least 32 characters)
@@ -45,24 +44,13 @@ state_dir: ./data
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
# Hosts that don't require signatures (default: none)
# Every image on an allowlisted host is served to anyone without a
# signature, at any size and in any format, so list only hosts you
# control, where nobody else can put images.
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
# An entry that is neither a host name nor an IP address (IPv6 without
# brackets), such as one with a port or a "*." wildcard, aborts startup.
# allowlist_hosts:
# - images.example.com
# Hosts whose pages may not show pixa's images, written as for
# allowlist_hosts. A request to /v1/image/ or /v1/e/ whose Referer header
# names one of them is answered 403 before anything is fetched, even when
# the image is cached. A request with no Referer, or one that does not
# parse, is served, so a site whose pages send no Referer is not stopped.
# The login and generator pages are not covered. (default: none)
# referer_blocklist:
# - leech.example
# - .hotlinker.example
allowlist_hosts:
- s3.sneak.cloud
- static.sneak.cloud
- sneak.berlin
- github.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
@@ -108,21 +96,12 @@ upstream_connections_per_host: 20
# downstream_timeout has ended first.
upstream_connections: 64
# Maximum number of images decoded and encoded at once (default: half the
# number of CPUs pixa can use, at least 1; the number of CPUs 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.
# 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
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
# may send per minute; a request over the limit is answered 429 with a
# Retry-After header. 0 turns the limit off. Clients are told apart as for
# the login limit, so behind a CDN or proxy whose addresses are not in
# trusted_proxies all requests arriving from one proxy or CDN address count
# as one client: raise the limit there, or set 0. (default: 600)
image_requests_per_minute: 600
# Time allowed for one fetch from an upstream host (default: 30s)
upstream_fetch_timeout: 30s
@@ -149,9 +128,8 @@ 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 sum of
# the free space on the filesystem containing <state_dir>/cache/ and
# the bytes of images the cache already holds, worked out at startup,
# 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
-70
View File
@@ -1,70 +0,0 @@
// Package app reads the pixad command line and runs the server.
package app
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"go.uber.org/fx"
"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"
"sneak.berlin/go/pixa/internal/server"
)
var configPath string //nolint:gochecknoglobals // cobra flag
// Run reads the command line and runs the server until it stops, with
// version as the version pixad logs and reports. It exits the process
// with status 1 when the command line is not valid.
func Run(version string) {
globals.Version = version
rootCmd := &cobra.Command{
Use: "pixad",
Short: "Pixa image caching proxy server",
Run: run,
}
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
err := rootCmd.Execute()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(_ *cobra.Command, _ []string) {
// Set config path in environment if specified via flag
if configPath != "" {
_ = os.Setenv("PIXA_CONFIG_PATH", configPath)
}
// A write to a closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
fx.New(
fx.Provide(
config.New,
database.New,
globals.New,
handlers.New,
logger.New,
server.New,
middleware.New,
healthcheck.New,
),
fx.Invoke(
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
}
+154 -13
View File
@@ -1,10 +1,26 @@
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
@@ -135,30 +151,155 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
}
}
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
// cache_max_bytes is recorded as not explicit, so the cache works out
// the default when it opens, and that an explicit zero is recorded as
// explicit, so it disables the disk cache instead.
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
// computed default is 75% of the probed free space when that exceeds
// the floor.
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
t.Parallel()
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
// 4 GiB free -> 3 GiB default.
probe := func(string) (uint64, error) { return 4294967296, nil }
omitted, err := configFromYAML(t, signingKeyLine)
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)
}
if omitted.CacheMaxBytesExplicit {
t.Error("omitted cache_max_bytes recorded as explicit")
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
}
zero, err := configFromYAML(t, signingKeyLine+"cache_max_bytes: 0\n")
err = c.resolveCacheMaxBytes(discardLogger(), probe)
if err != nil {
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
}
if !zero.CacheMaxBytesExplicit {
t.Error("cache_max_bytes: 0 not recorded as explicit")
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)
}
}
+116
View File
@@ -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
}
@@ -1,7 +1,6 @@
package config
import (
"fmt"
"runtime"
"testing"
)
@@ -13,8 +12,8 @@ const (
)
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
// max_concurrent_processing is half the CPUs Go uses, and at least 1, and
// an omitted upstream_connections is 64.
// max_concurrent_processing is the number of CPUs Go uses and an omitted
// upstream_connections is 64.
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
t.Parallel()
@@ -23,9 +22,9 @@ func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.MaxConcurrentProcessing != max(runtime.GOMAXPROCS(0)/2, 1) {
t.Errorf("MaxConcurrentProcessing = %d, want %d, half the CPUs",
c.MaxConcurrentProcessing, max(runtime.GOMAXPROCS(0)/2, 1))
if c.MaxConcurrentProcessing != runtime.GOMAXPROCS(0) {
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
}
if c.UpstreamConnections != 64 {
@@ -33,36 +32,6 @@ func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
}
}
// TestOmittedMaxConcurrentProcessingIsHalfTheCPUsAndAtLeastOne checks
// that an omitted max_concurrent_processing is half the CPUs Go uses,
// rounded down, and 1 when Go uses one CPU.
//
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
func TestOmittedMaxConcurrentProcessingIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
cpusBefore := runtime.GOMAXPROCS(0)
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
for _, tc := range []struct{ cpus, want int }{
{1, 1}, {2, 1}, {3, 1}, {8, 4},
} {
runtime.GOMAXPROCS(tc.cpus)
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
t.Logf("GOMAXPROCS %d: MaxConcurrentProcessing %d",
tc.cpus, c.MaxConcurrentProcessing)
if c.MaxConcurrentProcessing != tc.want {
t.Errorf("GOMAXPROCS %d: MaxConcurrentProcessing = %d, want %d",
tc.cpus, c.MaxConcurrentProcessing, tc.want)
}
}
}
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
// two limits are used as given.
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
@@ -191,19 +160,3 @@ func TestConcurrencyLimitsFromEnvironment(t *testing.T) {
})
}
}
// TestInvalidMaxConcurrentProcessingFromEnvironmentAbortsStartup checks
// that a PIXA_MAX_CONCURRENT_PROCESSING that is not a whole number of at
// least 1, an empty one included, aborts startup naming the variable and
// the key instead of taking the default.
func TestInvalidMaxConcurrentProcessingFromEnvironmentAbortsStartup(t *testing.T) {
for _, value := range []string{"", " ", "0.5", "half", "-1", "4 images"} {
t.Run(fmt.Sprintf("value %q", value), func(t *testing.T) {
t.Setenv(testMaxConcurrentProcessingVar, value)
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err,
testMaxConcurrentProcessingVar, keyMaxConcurrentProcessing)
})
}
}
+70 -159
View File
@@ -11,7 +11,6 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
@@ -34,17 +33,8 @@ const (
DefaultUpstreamFetchTimeout = 30 * time.Second
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
DefaultDownstreamTimeout = 60 * time.Second
DefaultImageRequestsPerMinute = 600
)
// DefaultMaxConcurrentProcessing returns the most images processed at once
// when max_concurrent_processing is omitted: half the CPUs Go uses
// (runtime.GOMAXPROCS(0), which follows a container's CPU limit), rounded
// down, and at least 1.
func DefaultMaxConcurrentProcessing() int {
return max(runtime.GOMAXPROCS(0)/2, 1) //nolint:mnd // half the CPUs
}
// Configuration key names.
const (
keyDebug = "debug"
@@ -58,7 +48,6 @@ const (
keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts"
keyRefererBlocklist = "referer_blocklist"
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyUpstreamConnections = "upstream_connections"
@@ -70,11 +59,10 @@ const (
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
keyUpstreamMaxResponseSize = "upstream_max_response_size"
keyDownstreamTimeout = "downstream_timeout"
keyImageRequestsPerMinute = "image_requests_per_minute"
)
// placeholderSigningKey is the dummy signing_key shipped in
// configs/config.example.yml. It is 45 characters, so it passes the length
// config.example.yml. It is 45 characters, so it passes the length
// check, but it is public in this repository and must be rejected at
// startup so no deployment ever signs URLs with it.
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
@@ -100,20 +88,23 @@ var (
errMustBeAtLeastOne = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short")
errPlaceholderKey = errors.New(
"is the placeholder from configs/config.example.yml; " +
"is the placeholder from config.example.yml; " +
"generate a real key with: openssl rand -base64 32")
errMustBeSetTogether = errors.New("must be set together")
errMustNotBeNegative = errors.New("must not be negative")
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
errValueNull = errors.New(
errNegativeBlockSize = errors.New(
"statfs reported negative block size")
errValueNull = errors.New(
"value is null; omit the key entirely to use the default")
errValuesNull = errors.New(
"value is null; omit a key entirely to use its default")
errNotAHost = errors.New("must be a host name such as " +
"cdn.example.com or .example.com, or an IP address")
errNotADuration = errors.New("not a duration such as 30s or 2m")
errMustBePositive = errors.New("must be positive")
errNotAnOrigin = errors.New(
errNotBareHostname = errors.New(
"must be a bare hostname without scheme, path, or whitespace")
errNoHostnameLabels = errors.New("contains no hostname labels")
errNotADuration = errors.New("not a duration such as 30s or 2m")
errMustBePositive = errors.New("must be positive")
errNotAnOrigin = errors.New(
`not "*" or an origin such as https://example.com`)
)
@@ -141,10 +132,6 @@ type Config struct {
AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// RefererBlocklist holds host patterns, matched as AllowlistHosts is: the
// image routes refuse a request whose Referer names a matching host.
RefererBlocklist []string
// UpstreamConnections is the most concurrent connections to all
// upstream hosts together, on top of the per-host limit.
// MaxConcurrentProcessing is the most images processed at once.
@@ -168,11 +155,6 @@ type Config struct {
// of the per-request timeout middleware.
DownstreamTimeout time.Duration
// ImageRequestsPerMinute is how many requests one client may make to
// the image routes per minute; the next is refused with 429 Too Many
// Requests. 0 turns the limit off.
ImageRequestsPerMinute int
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
// addition to the built-in SSRF blocklist. Enforced by the upstream
// fetcher's dialer; the built-in ranges always apply.
@@ -189,19 +171,18 @@ type Config struct {
// address, and an explicit list replaces the default.
TrustedProxies []netip.Prefix
// CacheMaxBytes is the disk cache size limit in bytes. Only an
// explicit zero (CacheMaxBytesExplicit true) disables the disk
// cache. Zero with CacheMaxBytesExplicit false means
// cache_max_bytes was omitted, and the cache works out the default
// limit when it opens.
// CacheMaxBytes is the disk cache size limit in bytes. Zero
// disables the disk cache entirely. When cache_max_bytes is
// omitted from the configuration, this holds the computed default
// (75% of free space on the filesystem containing
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
CacheMaxBytes int64
// CacheMaxBytesExplicit records whether cache_max_bytes was
// cacheMaxBytesExplicit records whether cache_max_bytes was
// explicitly set, in the environment or the configuration file.
// Explicit values are used exactly as given; for an omitted key the
// cache works out the default limit when it opens (see
// imgcache.CacheConfig.UseDefaultMaxBytes).
CacheMaxBytesExplicit bool
// Explicit values are used exactly as given; only an omitted key
// gets the computed default (and its floor) in resolveCacheMaxBytes.
cacheMaxBytesExplicit bool
}
// New creates a new Config instance from the environment and the
@@ -238,13 +219,9 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return nil, err
}
// An omitted cache_max_bytes is worked out and logged when the
// cache opens.
if c.CacheMaxBytesExplicit {
log.Info("effective cache size limit",
"cache_max_bytes", c.CacheMaxBytes,
"cache_disabled", c.CacheMaxBytes == 0,
)
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
if err != nil {
return nil, err
}
if c.Debug {
@@ -290,6 +267,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
}
loader := &strictLoader{sc: sc}
c := &Config{
Debug: loader.boolVal(keyDebug, false),
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
@@ -305,8 +283,10 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
UpstreamConnections: loader.intVal(
keyUpstreamConnections, DefaultUpstreamConnections),
// Decoding and encoding are CPU-bound, so the default is one image
// per CPU Go uses, which follows a container's CPU limit.
MaxConcurrentProcessing: loader.intVal(
keyMaxConcurrentProcessing, DefaultMaxConcurrentProcessing()),
keyMaxConcurrentProcessing, runtime.GOMAXPROCS(0)),
UpstreamFetchTimeout: loader.durationVal(
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
UpstreamMaxResponseSize: loader.int64Val(
@@ -315,18 +295,17 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
DownstreamTimeout: loader.durationVal(
keyDownstreamTimeout, DefaultDownstreamTimeout),
ImageRequestsPerMinute: loader.intVal(
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
RefererBlocklist: loader.hostListVal(keyRefererBlocklist),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
}
// The default for an omitted cache_max_bytes is worked out when
// the cache opens; here we only record whether the operator set
// the key explicitly.
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
// The computed default for cache_max_bytes needs a validated
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
// we only record whether the operator set the key explicitly.
if _, present := lookupValue(sc, keyCacheMaxBytes); present {
c.cacheMaxBytesExplicit = true
}
// Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an
@@ -338,8 +317,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
settingName(keyDBURL), errValueEmpty)
}
// The driver sets the journal mode only through a _pragma parameter.
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
}
if loader.err != nil {
@@ -438,8 +416,7 @@ func isKnownConfigKey(key string) bool {
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
keyImageRequestsPerMinute, "env":
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
return true
}
@@ -462,7 +439,6 @@ func envVarNames() map[string]string {
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
keySigningKey: "PIXA_SIGNING_KEY",
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
keyRefererBlocklist: "PIXA_REFERER_BLOCKLIST",
keyAllowHTTP: "PIXA_ALLOW_HTTP",
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
@@ -474,7 +450,6 @@ func envVarNames() map[string]string {
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
}
}
@@ -575,8 +550,8 @@ func (c *Config) ensureStateDirWritable() error {
}
// validateSigningKey checks that the signing key is present, long
// enough, and not the public placeholder from configs/config.example.yml.
// The key value itself is never echoed in error messages.
// enough, and not the public placeholder from config.example.yml. The
// key value itself is never echoed in error messages.
func (c *Config) validateSigningKey() error {
if c.SigningKey == "" {
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
@@ -627,29 +602,24 @@ func (c *Config) validate() error {
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
}
// Zero is valid (it turns the limit off); only negative values are
// rejected.
if c.ImageRequestsPerMinute < 0 {
return fmt.Errorf("%s: value %d %w",
settingName(keyImageRequestsPerMinute), c.ImageRequestsPerMinute,
errMustNotBeNegative)
}
err = c.validateUpstreamMaxResponseSize()
if err != nil {
return err
}
for _, host := range c.AllowlistHosts {
err := validateHostPattern(keyAllowlistHosts, host)
err := validateAllowlistHost(host)
if err != nil {
return err
}
}
err = c.validateSentryDSN()
if err != nil {
return err
if c.SentryDSN != "" {
parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("%s: value %q is %w",
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
}
}
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
@@ -661,22 +631,6 @@ func (c *Config) validate() error {
return c.validateAccessControlAllowOrigin()
}
// validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme
// and a host.
func (c *Config) validateSentryDSN() error {
if c.SentryDSN == "" {
return nil
}
parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("%s: value %q is %w",
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
}
return nil
}
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
// is from 1 byte to 1 GiB. An upstream response is read whole into
// memory, and the image processor reads one byte past this limit, which
@@ -777,24 +731,25 @@ func (c *Config) validateConcurrencyLimits() error {
return nil
}
// hostNamePattern matches a host name: letters, digits, hyphens, underscores
// and dots, optionally after one leading dot.
var hostNamePattern = regexp.MustCompile(`^\.?[A-Za-z0-9_-][A-Za-z0-9_.-]*$`)
// validateHostPattern checks that an entry of the named key, allowlist_hosts
// or referer_blocklist, is an IP address or a host name, the host name
// optionally with one leading dot for suffix matching. Anything else, such as
// a URL, a port or a "*." wildcard, can never match a host name that resolves,
// so it is refused.
// So is "." alone: the allowlist matcher would match it against any host
// written with a trailing dot, which in allowlist_hosts disables URL signing.
func validateHostPattern(key, host string) error {
_, err := netip.ParseAddr(host)
if err == nil || hostNamePattern.MatchString(host) {
return nil
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
// hostname, optionally with a leading dot for suffix matching. URLs,
// paths, and whitespace indicate a misconfigured entry. An entry with
// no hostname labels (such as ".") is rejected: the allowlist matcher
// treats a leading dot as a suffix pattern, so a bare "." would match
// any upstream host written in FQDN trailing-dot form and effectively
// disable URL signing.
func validateAllowlistHost(host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf("%s: entry %q %w",
settingName(keyAllowlistHosts), host, errNotBareHostname)
}
return fmt.Errorf("%s: entry %q %w", settingName(key), host, errNotAHost)
if strings.Trim(host, ".") == "" {
return fmt.Errorf("%s: entry %q %w",
settingName(keyAllowlistHosts), host, errNoHostnameLabels)
}
return nil
}
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
@@ -829,7 +784,6 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
// one whose path runs through a file, such as under a HOME of
// /dev/null. One that cannot be read or does not parse is a
// fatal startup error.
//nolint:gosec // G703: a fixed config location; HOME is the operator's
_, statErr := os.Stat(cleanPath)
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
continue
@@ -925,19 +879,6 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
return val
}
func (l *strictLoader) hostListVal(key string) []string {
if l.err != nil {
return nil
}
val, err := parseHostList(l.sc, key)
if err != nil {
l.err = err
}
return val
}
// getString returns the string value for key, or defaultVal if the key
// is omitted. A present value that is not a string, or is explicitly
// null, is an error.
@@ -1235,7 +1176,7 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return nil, errNullConfigValue(key)
}
entries, err := listEntries(raw, key)
entries, err := cidrListEntries(raw, key)
if err != nil {
return nil, err
}
@@ -1255,41 +1196,11 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return prefixes, nil
}
// parseHostList parses the value of the named config key into host patterns,
// or returns nil if the key is omitted. It accepts a YAML list of strings or a
// comma-separated string. An explicitly null value, a wrong type, an empty
// entry, a non-string entry, or an entry validateHostPattern rejects aborts
// startup naming the key and the offending value.
func parseHostList(sc *smartconfig.Config, key string) ([]string, error) {
raw, ok := lookupValue(sc, key)
if !ok {
return nil, nil
}
if raw == nil {
return nil, errNullConfigValue(key)
}
entries, err := listEntries(raw, key)
if err != nil {
return nil, err
}
for _, entry := range entries {
err := validateHostPattern(key, entry)
if err != nil {
return nil, err
}
}
return entries, nil
}
// listEntries extracts the raw entries of the named list key as trimmed,
// non-empty strings, from either a YAML list of strings or a comma-separated
// string; an empty string is an empty list, as for allowlist_hosts. Any other
// shape is a configuration error.
func listEntries(raw any, key string) ([]string, error) {
// cidrListEntries extracts the raw entries of the named CIDR-list key as
// trimmed, non-empty strings, from either a YAML list of strings or a
// comma-separated string; an empty string is an empty list, as for
// allowlist_hosts. Any other shape is a configuration error.
func cidrListEntries(raw any, key string) ([]string, error) {
switch val := raw.(type) {
case []any:
entries := make([]string, 0, len(val))
-26
View File
@@ -92,32 +92,6 @@ func TestGetStringSlice_Empty(t *testing.T) {
}
}
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
// with a real signing key, as Getting Started in README.md has it copied
// and edited, and checks that it allowlists no host: every image on an
// allowlisted host is served to anyone without a signature.
func TestExampleConfigAllowlistsNoHost(t *testing.T) {
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
sc, err := loadTestConfig(examplePath)
if err != nil {
t.Fatalf("failed to load %s: %v", examplePath, err)
}
c, err := newFromSmartConfig(sc)
if err != nil {
t.Fatalf("%s is not a valid config: %v", examplePath, err)
}
t.Logf("allowlist_hosts in %s: %v", examplePath, c.AllowlistHosts)
if len(c.AllowlistHosts) != 0 {
t.Errorf("%s allowlists %v, want no host", examplePath, c.AllowlistHosts)
}
}
// loadTestConfig is a helper to load a config file for testing.
func loadTestConfig(path string) (*smartconfig.Config, error) {
return smartconfig.NewFromConfigPath(path)
@@ -1,18 +1,14 @@
package config
import (
"database/sql"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/smartconfig"
_ "modernc.org/sqlite" // SQLite driver registration
)
// validTestSigningKey is a 32-character signing key that satisfies the
@@ -98,43 +94,12 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
}
wantDBURL := "file:" + DefaultStateDir +
"/state.sqlite3?_pragma=journal_mode(WAL)"
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
if c.DBURL != wantDBURL {
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
}
}
// TestDefaultDBURLOpensTheDatabaseInWALMode opens the db_url derived from
// state_dir with the SQLite driver pixad uses and checks that the database
// is in WAL mode: the driver ignores any parameter it does not know.
func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+t.TempDir()+"\n")
if err != nil {
t.Fatalf("config with only state_dir set should be valid, got: %v", err)
}
db, err := sql.Open("sqlite", c.DBURL)
if err != nil {
t.Fatalf("failed to open %q: %v", c.DBURL, err)
}
t.Cleanup(func() { _ = db.Close() })
var journalMode string
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&journalMode)
if err != nil {
t.Fatalf("failed to read the journal mode of %q: %v", c.DBURL, err)
}
if journalMode != "wal" {
t.Errorf("journal mode of %q = %q, want wal", c.DBURL, journalMode)
}
}
func TestExplicitValidValuesAreUsed(t *testing.T) {
t.Parallel()
@@ -217,25 +182,6 @@ func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
}
}
// TestAllowlistHostsAcceptsUnderscore checks that an upstream host name with
// an underscore, which pixa can fetch from, is accepted as an entry.
func TestAllowlistHostsAcceptsUnderscore(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`allowlist_hosts:
- my_bucket.example.com
- .my_bucket.example.org
`)
if err != nil {
t.Fatalf("host names with an underscore should load, got error: %v", err)
}
want := []string{"my_bucket.example.com", ".my_bucket.example.org"}
if !slices.Equal(c.AllowlistHosts, want) {
t.Errorf("AllowlistHosts = %v, want %v", c.AllowlistHosts, want)
}
}
// runAbortCases asserts that each case's config aborts startup with an
// error message mentioning every expected substring.
func runAbortCases(t *testing.T, cases []abortCase) {
@@ -338,20 +284,6 @@ func invalidHostAndCredentialCases() []abortCase {
keyAllowlistHosts, "example.com/images",
},
},
{
name: "allowlist host with wildcard",
yaml: signingKeyLine + "allowlist_hosts:\n - \"*.example.com\"\n",
wantErrSubstrings: []string{
keyAllowlistHosts, "*.example.com",
},
},
{
name: "allowlist host with port",
yaml: signingKeyLine + "allowlist_hosts:\n - example.com:8443\n",
wantErrSubstrings: []string{
keyAllowlistHosts, "example.com:8443",
},
},
{
name: "allowlist host with whitespace",
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
+1 -5
View File
@@ -65,12 +65,10 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
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_REFERER_BLOCKLIST", "hotlinker.example,.leech.example")
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_IMAGE_REQUESTS_PER_MINUTE", "30")
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
@@ -95,14 +93,12 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
MetricsPassword: "metricspass",
SigningKey: validTestSigningKey,
AllowlistHosts: []string{testHostS3, ".example.com"},
RefererBlocklist: []string{"hotlinker.example", ".leech.example"},
AllowHTTP: true,
UpstreamConnectionsPerHost: 5,
UpstreamConnections: 10,
MaxConcurrentProcessing: 3,
ImageRequestsPerMinute: 30,
CacheMaxBytes: 1024,
CacheMaxBytesExplicit: true,
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",
@@ -1,107 +0,0 @@
package config
import "testing"
// testImageRequestsPerMinuteVar is the variable that sets
// image_requests_per_minute.
const testImageRequestsPerMinuteVar = "PIXA_IMAGE_REQUESTS_PER_MINUTE"
// TestImageRequestsPerMinuteValues checks that an omitted
// image_requests_per_minute is 600, and that 0, which turns the limit off,
// and any larger whole number are used as given.
func TestImageRequestsPerMinuteValues(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.ImageRequestsPerMinute != 600 {
t.Errorf("ImageRequestsPerMinute = %d, want 600", c.ImageRequestsPerMinute)
}
explicit := []struct {
value string
want int
}{
{"0", 0},
{"30", 30},
}
for _, tc := range explicit {
c, err := configFromYAML(t,
signingKeyLine+"image_requests_per_minute: "+tc.value+"\n")
if err != nil {
t.Fatalf("image_requests_per_minute: %s should load, got error: %v",
tc.value, err)
}
if c.ImageRequestsPerMinute != tc.want {
t.Errorf("ImageRequestsPerMinute = %d, want %d",
c.ImageRequestsPerMinute, tc.want)
}
}
}
// TestInvalidImageRequestsPerMinuteAbortsStartup checks that a value that is
// negative, not a whole number, or null aborts startup naming the key and
// the value, and the variable too where the value could have come from it.
func TestInvalidImageRequestsPerMinuteAbortsStartup(t *testing.T) {
t.Parallel()
key := keyImageRequestsPerMinute
runAbortCases(t, []abortCase{
{
name: "negative",
yaml: signingKeyLine + key + ": -1\n",
wantErrSubstrings: []string{
key, testImageRequestsPerMinuteVar, "value -1",
},
},
{
name: "not a number",
yaml: signingKeyLine + key + ": plenty\n",
wantErrSubstrings: []string{
key, testImageRequestsPerMinuteVar, "plenty",
},
},
{
name: "fractional",
yaml: signingKeyLine + key + ": 2.5\n",
wantErrSubstrings: []string{key, "2.5"},
},
{
name: "explicit null",
yaml: signingKeyLine + key + ": null\n",
wantErrSubstrings: []string{key, nullValueText},
},
})
}
// TestImageRequestsPerMinuteFromEnvironment checks that the variable sets the
// limit over the config file, and that an invalid value in it aborts startup
// naming the variable and the value.
func TestImageRequestsPerMinuteFromEnvironment(t *testing.T) {
t.Setenv(testImageRequestsPerMinuteVar, "0")
c, err := configFromYAML(t, signingKeyLine+"image_requests_per_minute: 30\n")
if err != nil {
t.Fatalf("limit from the environment should load: %v", err)
}
if c.ImageRequestsPerMinute != 0 {
t.Errorf("ImageRequestsPerMinute = %d, want 0 from the environment",
c.ImageRequestsPerMinute)
}
for _, value := range []string{"-5", "ten"} {
t.Run(value, func(t *testing.T) {
t.Setenv(testImageRequestsPerMinuteVar, value)
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, testImageRequestsPerMinuteVar, value)
})
}
}
@@ -1,166 +0,0 @@
package config
import (
"slices"
"testing"
)
// TestRefererBlocklistParsed loads a referer_blocklist with a host and a
// pattern starting with "." and checks both are kept in order.
func TestRefererBlocklistParsed(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`referer_blocklist:
- leech.example
- .hotlinker.example
`)
if err != nil {
t.Fatalf("valid referer_blocklist should load, got error: %v", err)
}
want := []string{"leech.example", ".hotlinker.example"}
if !slices.Equal(c.RefererBlocklist, want) {
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
}
}
// TestRefererBlocklistAcceptsIPAddresses checks that IPv4 and IPv6 addresses,
// the IPv6 one written without brackets, are accepted as entries.
func TestRefererBlocklistAcceptsIPAddresses(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`referer_blocklist:
- 192.0.2.7
- "2001:db8::7"
`)
if err != nil {
t.Fatalf("IP address entries should load, got error: %v", err)
}
want := []string{"192.0.2.7", "2001:db8::7"}
if !slices.Equal(c.RefererBlocklist, want) {
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
}
}
// TestRefererBlocklistAcceptsUnderscore checks that a host name with an
// underscore, which a page can be served from, is accepted as an entry.
func TestRefererBlocklistAcceptsUnderscore(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`referer_blocklist:
- my_site.leech.example
- .my_site.hotlinker.example
`)
if err != nil {
t.Fatalf("host names with an underscore should load, got error: %v", err)
}
want := []string{"my_site.leech.example", ".my_site.hotlinker.example"}
if !slices.Equal(c.RefererBlocklist, want) {
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
}
}
// TestRefererBlocklistOmittedIsEmpty checks that an omitted key blocks no
// referer.
func TestRefererBlocklistOmittedIsEmpty(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.RefererBlocklist) != 0 {
t.Errorf("RefererBlocklist = %v, want empty", c.RefererBlocklist)
}
}
// TestRefererBlocklistInvalidAbortsStartup checks that an entry that is not a
// host, or a value that is not a list of them, aborts startup with an error
// naming the key and the entry.
func TestRefererBlocklistInvalidAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "entry with a scheme",
yaml: signingKeyLine + "referer_blocklist:\n - https://leech.example\n",
wantErrSubstrings: []string{
keyRefererBlocklist, "https://leech.example",
},
},
{
name: "entry with a path",
yaml: signingKeyLine + "referer_blocklist:\n - leech.example/page\n",
wantErrSubstrings: []string{
keyRefererBlocklist, "leech.example/page",
},
},
{
name: "wildcard entry",
yaml: signingKeyLine + "referer_blocklist:\n - \"*.leech.example\"\n",
wantErrSubstrings: []string{
keyRefererBlocklist, "*.leech.example",
},
},
{
name: "entry with a port",
yaml: signingKeyLine + "referer_blocklist:\n - leech.example:8080\n",
wantErrSubstrings: []string{
keyRefererBlocklist, "leech.example:8080",
},
},
{
name: "two leading dots",
yaml: signingKeyLine + "referer_blocklist:\n - ..leech.example\n",
wantErrSubstrings: []string{
keyRefererBlocklist, "..leech.example",
},
},
{
name: "dot only",
yaml: signingKeyLine + "referer_blocklist:\n - \".\"\n",
wantErrSubstrings: []string{keyRefererBlocklist, `"."`},
},
{
name: "empty entry",
yaml: signingKeyLine + "referer_blocklist:\n - \"\"\n",
wantErrSubstrings: []string{keyRefererBlocklist},
},
{
name: "entry not a string",
yaml: signingKeyLine + "referer_blocklist:\n - 42\n",
wantErrSubstrings: []string{keyRefererBlocklist, "42"},
},
{
name: "null value",
yaml: signingKeyLine + "referer_blocklist:\n",
wantErrSubstrings: []string{keyRefererBlocklist, nullValueText},
},
})
}
// TestRefererBlocklistFromEnvironment checks that PIXA_REFERER_BLOCKLIST
// takes comma-separated entries, and that an entry in it that is not a host
// aborts startup naming the variable and the entry.
func TestRefererBlocklistFromEnvironment(t *testing.T) {
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
t.Setenv("PIXA_REFERER_BLOCKLIST", " leech.example , .hotlinker.example ")
c, err := newFromSmartConfig(nil)
if err != nil {
t.Fatalf("valid PIXA_REFERER_BLOCKLIST should load, got error: %v", err)
}
want := []string{"leech.example", ".hotlinker.example"}
if !slices.Equal(c.RefererBlocklist, want) {
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
}
t.Setenv("PIXA_REFERER_BLOCKLIST", "leech.example,https://hotlinker.example")
_, err = newFromSmartConfig(nil)
wantStartupError(t, err, "PIXA_REFERER_BLOCKLIST", "https://hotlinker.example")
}
@@ -1,152 +0,0 @@
package database
import (
"context"
"database/sql"
"fmt"
"log/slog"
"path/filepath"
"sync"
"testing"
"sneak.berlin/go/pixa/internal/config"
)
// TestConcurrentWritesAllSucceed opens a database the way pixad does and
// writes to it from several goroutines at once, as one request's writes and
// the background eviction pass do. Every write must succeed, none failing
// with "database is locked", whether or not db_url already has parameters,
// and the parameters it has must still apply.
func TestConcurrentWritesAllSucceed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
query string
wantJournalMode string
}{
{
name: "db_url without parameters",
query: "",
wantJournalMode: "delete",
},
{
name: "db_url with the WAL parameter",
query: "?_pragma=journal_mode(WAL)",
wantJournalMode: "wal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dbURL := "file:" + filepath.Join(t.TempDir(), "state.sqlite3") + tt.query
d := &Database{
log: slog.New(slog.DiscardHandler),
config: &config.Config{DBURL: dbURL},
}
err := d.connect(t.Context())
if err != nil {
t.Fatalf("failed to connect to %q: %v", dbURL, err)
}
t.Cleanup(func() { _ = d.db.Close() })
writeConcurrently(t, d.db)
var journalMode string
err = d.db.QueryRowContext(t.Context(), "PRAGMA journal_mode").
Scan(&journalMode)
if err != nil {
t.Fatalf("failed to read the journal mode: %v", err)
}
if journalMode != tt.wantJournalMode {
t.Errorf("journal mode = %q, want %q", journalMode, tt.wantJournalMode)
}
})
}
}
// writeConcurrently runs writeLikeOneRequest from several goroutines at once
// and checks that every write was made.
func writeConcurrently(t *testing.T, db *sql.DB) {
t.Helper()
const (
writers = 4
requestsEach = 20
totalRequests = writers * requestsEach
)
ctx := t.Context()
var wg sync.WaitGroup
for writer := range writers {
wg.Go(func() {
for request := range requestsEach {
key := fmt.Sprintf("%d-%d", writer, request)
err := writeLikeOneRequest(ctx, db, key)
if err != nil {
t.Errorf("writer %d: %v", writer, err)
return
}
}
})
}
wg.Wait()
var hits, sources int
err := db.QueryRowContext(ctx, `
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
FROM cache_stats WHERE id = 1
`).Scan(&hits, &sources)
if err != nil {
t.Fatalf("failed to count the writes: %v", err)
}
if hits != totalRequests || sources != totalRequests {
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
hits, sources, totalRequests)
}
}
// writeLikeOneRequest makes the writes one request and the eviction pass
// make: it counts a cache hit, stores a source, records a transformed image
// and deletes that record again.
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
_, err := db.ExecContext(ctx,
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
if err != nil {
return fmt.Errorf("counting a cache hit: %w", err)
}
_, err = db.ExecContext(ctx, `INSERT INTO source_content
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
if err != nil {
return fmt.Errorf("storing a source: %w", err)
}
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
if err != nil {
return fmt.Errorf("recording a transformed image: %w", err)
}
_, err = db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, key)
if err != nil {
return fmt.Errorf("evicting a transformed image: %w", err)
}
return nil
}
+2 -18
View File
@@ -237,24 +237,13 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
return nil
}
// DB returns the underlying sql.DB. It has one connection, so close any
// rows and end any transaction before running another query on it; a
// query run while they are open waits forever.
// DB returns the underlying sql.DB.
func (s *Database) DB() *sql.DB {
return s.db
}
func (s *Database) connect(ctx context.Context) error {
// With a busy timeout, a write that finds another program writing to
// the same database file waits up to five seconds for it instead of
// failing at once with "database is locked". The driver runs each
// _pragma parameter on every connection it opens.
separator := "?"
if strings.Contains(s.config.DBURL, "?") {
separator = "&"
}
dbURL := s.config.DBURL + separator + "_pragma=busy_timeout(5000)"
dbURL := s.config.DBURL
s.log.Info("connecting to database", "url", dbURL)
@@ -265,11 +254,6 @@ func (s *Database) connect(ctx context.Context) error {
return err
}
// One connection: pixa's own reads and writes run on it one at a
// time instead of competing for SQLite's lock, where a write that
// keeps losing can wait past the busy timeout and be lost.
db.SetMaxOpenConns(1)
err = db.PingContext(ctx)
if err != nil {
s.log.Error("failed to ping database", "error", err)
+6 -79
View File
@@ -3,12 +3,14 @@
-- Source content blobs
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
-- last_accessed_at is NULL until the first LRU touch; eviction falls
-- back to fetched_at for rows that have never been touched.
CREATE TABLE IF NOT EXISTS source_content (
content_hash TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
last_accessed_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
ON source_content(last_accessed_at);
@@ -40,8 +42,9 @@ CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(conte
-- Processed variant blobs
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
-- sidecar with the content type). Tracked here (like source content
-- blobs above) so total cache usage is known without a directory scan,
-- and so LRU eviction has a timestamp to order on.
-- blobs above) so total cache usage can be computed with a SUM query,
-- never a directory scan, and so LRU eviction has a timestamp to order
-- on.
CREATE TABLE IF NOT EXISTS variant_content (
cache_key TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
@@ -52,74 +55,6 @@ CREATE TABLE IF NOT EXISTS variant_content (
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
ON variant_content(last_accessed_at);
-- Total cache usage: the sum of size_bytes over source_content and
-- variant_content, kept by the triggers below in the same statement
-- that adds, removes or resizes a row, so eviction reads this one row
-- instead of summing both tables. Each trigger also adds one to
-- change_count; the reconciliation pass, which sums both tables a page
-- at a time, corrects total_size_bytes only if change_count did not
-- move while it summed.
CREATE TABLE IF NOT EXISTS cache_usage (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_size_bytes INTEGER NOT NULL DEFAULT 0,
change_count INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO cache_usage (id) VALUES (1);
CREATE TRIGGER IF NOT EXISTS source_content_usage_insert
AFTER INSERT ON source_content
BEGIN
UPDATE cache_usage
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
change_count = change_count + 1
WHERE id = 1;
END;
CREATE TRIGGER IF NOT EXISTS source_content_usage_delete
AFTER DELETE ON source_content
BEGIN
UPDATE cache_usage
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
change_count = change_count + 1
WHERE id = 1;
END;
CREATE TRIGGER IF NOT EXISTS source_content_usage_update
AFTER UPDATE OF size_bytes ON source_content
BEGIN
UPDATE cache_usage
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes,
change_count = change_count + 1
WHERE id = 1;
END;
CREATE TRIGGER IF NOT EXISTS variant_content_usage_insert
AFTER INSERT ON variant_content
BEGIN
UPDATE cache_usage
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
change_count = change_count + 1
WHERE id = 1;
END;
CREATE TRIGGER IF NOT EXISTS variant_content_usage_delete
AFTER DELETE ON variant_content
BEGIN
UPDATE cache_usage
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
change_count = change_count + 1
WHERE id = 1;
END;
CREATE TRIGGER IF NOT EXISTS variant_content_usage_update
AFTER UPDATE OF size_bytes ON variant_content
BEGIN
UPDATE cache_usage
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes,
change_count = change_count + 1
WHERE id = 1;
END;
-- Output/transformed content blobs
-- Not written: transformed images are stored in cache/variants and
-- tracked in variant_content above.
@@ -176,11 +111,3 @@ CREATE TABLE IF NOT EXISTS cache_stats (
last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
-- A random value that a login session carries from its login. pixa makes it
-- when the table has no row, and each logout replaces it; a login session
-- carrying another value is refused, so logging out ends every login session.
CREATE TABLE IF NOT EXISTS logout (
id INTEGER PRIMARY KEY CHECK (id = 1),
logout_value TEXT NOT NULL
);
+2 -2
View File
@@ -14,7 +14,7 @@ import (
// Default values for optional fields.
const (
DefaultQuality = 85
DefaultFormat = imgcache.FormatJXL
DefaultFormat = imgcache.FormatOriginal
DefaultFitMode = imgcache.FitCover
// HKDF salt for URL encryption key derivation
@@ -37,7 +37,7 @@ type Payload struct {
SourceQuery string `cbor:"q,omitempty"` // optional
Width int `cbor:"w,omitempty"` // 0 = original
Height int `cbor:"ht,omitempty"` // 0 = original
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
Quality int `cbor:"ql,omitempty"` // default: 85
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
+2 -2
View File
@@ -7,8 +7,8 @@ import (
const appname = "pixad"
// Version is set by app.Run to the version main was built with.
var Version string //nolint:gochecknoglobals // set by app.Run
// Version is populated from main() via ldflags.
var Version string //nolint:gochecknoglobals // set from main
// Globals holds application-wide constants.
type Globals struct {
+5 -18
View File
@@ -89,18 +89,10 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout logs out, which ends every login session (see
// session.Manager.Logout), and redirects to login.
// HandleLogout clears the session and redirects to login.
func (s *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := s.sessMgr.Logout(w, r)
if err != nil {
s.log.Error("failed to log out", "error", err)
http.Error(w, "Failed to log out", http.StatusInternalServerError)
return
}
s.sessMgr.ClearSession(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
@@ -377,15 +369,10 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri
scheme = "http"
}
// Determine file extension for the trailing filename. A form with no
// format makes a token with none, which is served as encurl.DefaultFormat.
// Determine file extension for the trailing filename
ext := format
switch format {
case "":
ext = string(encurl.DefaultFormat)
case "orig", "auto":
ext = "jpg"
if ext == "" || ext == "orig" {
ext = "jpg" // Default extension
}
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
@@ -1,384 +0,0 @@
package handlers
import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/session"
)
// formatField is the generator form's format field name.
const formatField = "format"
// Markers telling the login page from the generator page.
const (
loginForm = `action="/"`
loginKeyInput = `name="key"`
generatorForm = `action="/generate"`
)
// generatedURLPattern extracts the path of the URL the generator page shows.
// The test router runs with debug on, so the URL starts with http, and its
// host is httptest's default request host.
var generatedURLPattern = regexp.MustCompile(
`value="http://example\.com(/v1/e/[^"]+)"`)
// findSessionCookie returns the session cookie rec sets, or nil if it sets
// none.
func findSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == session.CookieName {
return c
}
}
return nil
}
// TestHandleRoot_NoSession_ShowsLoginForm verifies that GET / without a
// login session shows the login form.
func TestHandleRoot_NoSession_ShowsLoginForm(t *testing.T) {
t.Parallel()
_, srv := newCSRFTestRouter(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
t.Errorf("page is not the login form: %s", body)
}
}
// TestLoginPost_WrongKey_ShowsErrorWithoutSession verifies that a wrong key
// shows the login form again with an error, and sets no session cookie.
func TestLoginPost_WrongKey_ShowsErrorWithoutSession(t *testing.T) {
t.Parallel()
_, srv := newCSRFTestRouter(t)
cookies, token := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookies, url.Values{
loginKeyField: {"wrong-signing-key-fedcba9876543210"},
csrfTokenField: {token},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
t.Errorf("page is not the login form: %s", body)
}
if !strings.Contains(body, "Invalid signing key") {
t.Error("login form does not show the error")
}
if c := findSessionCookie(rec); c != nil {
t.Errorf("wrong key set a session cookie: %s", c)
}
}
// TestLoginPost_RightKey_SetsSessionCookie verifies that the right key answers
// 303 to / with a session cookie marked Secure, HttpOnly and SameSite=Strict,
// and that GET / with that cookie shows the generator page.
func TestLoginPost_RightKey_SetsSessionCookie(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 || rec.Header().Get("Location") != "/" {
t.Fatalf("status = %d, Location = %q, want %d to /",
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
}
sessionCookie := findSessionCookie(rec)
if sessionCookie == nil {
t.Fatal("right key set no session cookie")
}
t.Logf("Set-Cookie: %s", sessionCookie)
if !sessionCookie.Secure {
t.Error("session cookie is not Secure")
}
if !sessionCookie.HttpOnly {
t.Error("session cookie is not HttpOnly")
}
if sessionCookie.SameSite != http.SameSiteStrictMode {
t.Errorf("session cookie SameSite = %v, want Strict", sessionCookie.SameSite)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), generatorForm) {
t.Errorf("GET / with the session cookie: status = %d, "+
"want %d and the generator page", rec.Code, http.StatusOK)
}
}
// TestHandleLogout_ClearsSessionCookie verifies that GET /logout answers 303
// to / and replaces the session cookie with an empty one sent with
// Max-Age=0, which makes the browser delete it.
func TestHandleLogout_ClearsSessionCookie(t *testing.T) {
t.Parallel()
h, _ := newCSRFTestRouter(t)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/logout", nil)
req.AddCookie(newSessionCookie(t, h))
rec := httptest.NewRecorder()
h.HandleLogout().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("status = %d, Location = %q, want %d to /",
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
}
t.Logf("Set-Cookie: %s", rec.Header().Get("Set-Cookie"))
sessionCookie := findSessionCookie(rec)
if sessionCookie == nil {
t.Fatal("logout did not set the session cookie")
}
if sessionCookie.Value != "" {
t.Errorf("session cookie value = %q, want empty", sessionCookie.Value)
}
// net/http reads a Max-Age=0 attribute back as MaxAge -1.
if sessionCookie.MaxAge != -1 {
t.Errorf("session cookie MaxAge = %d, want -1 (Max-Age=0)",
sessionCookie.MaxAge)
}
}
// TestHandleLogout_EndsCopiedSession verifies that after POST /logout a copy
// of the session cookie taken before it shows the login page, not the
// generator page.
func TestHandleLogout_EndsCopiedSession(t *testing.T) {
t.Parallel()
h, srv := newCSRFTestRouter(t)
copied := newSessionCookie(t, h)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/logout", nil)
req.AddCookie(copied)
rec := httptest.NewRecorder()
h.HandleLogout().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /logout status = %d, want %d",
rec.Code, http.StatusSeeOther)
}
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(copied)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
body := rec.Body.String()
t.Logf("GET / with the copied cookie: status %d, login page %v",
rec.Code, strings.Contains(body, loginForm))
if !strings.Contains(body, loginForm) || strings.Contains(body, generatorForm) {
t.Error("a cookie copied before /logout still opens the generator page")
}
}
// TestHandleLogout_SaveFails_Answers500 verifies that when the logout cannot
// be saved in the database, POST /logout answers 500, leaves the session
// cookie in place and ends no session.
func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
t.Parallel()
h, srv := newCSRFTestRouter(t)
db := setupTestDB(t)
sessMgr, err := session.NewManagerWithDatabase(
t.Context(), testSigningKey, db)
if err != nil {
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
}
h.sessMgr = sessMgr
cookie := newSessionCookie(t, h)
// Saving the logout fails on a closed database.
err = db.Close()
if err != nil {
t.Fatalf("closing the database: %v", err)
}
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
h.HandleLogout().ServeHTTP(rec, req)
t.Logf("POST /logout: status %d, Set-Cookie %q",
rec.Code, rec.Header().Get("Set-Cookie"))
if rec.Code != http.StatusInternalServerError {
t.Errorf("POST /logout status = %d, want %d",
rec.Code, http.StatusInternalServerError)
}
if c := findSessionCookie(rec); c != nil {
t.Errorf("a logout that failed set the session cookie: %s", c)
}
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), generatorForm) {
t.Error("a logout that failed ended the session")
}
}
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
// with a valid CSRF token but no login session answers 303 to / and makes no
// URL.
func TestGeneratePost_NoSession_RedirectsToLogin(t *testing.T) {
t.Parallel()
_, srv := newCSRFTestRouter(t)
cookies, token := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/generate", cookies, url.Values{
sourceURLField: {testSourceURL},
csrfTokenField: {token},
})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("status = %d, Location = %q, want %d to /",
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
}
if strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("a URL was made without a login session")
}
}
// TestGeneratePost_URLServesImage verifies that the URL the generator page
// makes is served by /v1/e/. The image route runs on handlers of its own,
// made with the same signing key.
func TestGeneratePost_URLServesImage(t *testing.T) {
t.Parallel()
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
rec := generatePost(t, url.Values{
sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"},
heightField: {"50"},
formatField: {string(imgcache.FormatJPEG)},
})
if rec.Code != http.StatusOK {
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
}
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
if match == nil {
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
}
t.Logf("generated URL path: %s", match[1])
imageRec := httptest.NewRecorder()
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, match[1], nil))
requireServedPhoto(t, imageRec)
}
// TestGeneratePost_URLWithTTLExpires verifies that a URL the generator page
// makes with a ttl of one second is served by /v1/e/ at once and answers 410
// once the ttl has passed.
func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
t.Parallel()
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
rec := generatePost(t, url.Values{
sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"},
heightField: {"50"},
formatField: {string(imgcache.FormatJPEG)},
ttlField: {"1"},
})
if rec.Code != http.StatusOK {
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
}
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
if match == nil {
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
}
imageRec := httptest.NewRecorder()
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, match[1], nil))
requireServedPhoto(t, imageRec)
// The URL keeps the time it expires in whole seconds and is served
// through the whole of that second, so a ttl of one second has passed
// for certain two seconds after the URL was made.
time.Sleep(2 * time.Second)
imageRec = httptest.NewRecorder()
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, match[1], nil))
t.Logf("GET %s after the ttl: %d %q", match[1], imageRec.Code, imageRec.Body)
if imageRec.Code != http.StatusGone {
t.Errorf("status after the ttl = %d, want %d",
imageRec.Code, http.StatusGone)
}
}
@@ -1,49 +0,0 @@
package handlers
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and
// handleImageError, which answer the errors of the two image routes, answer
// 400 naming the AVIF pixel limit when the image processor refuses an AVIF
// output above it, wrapped as the service wraps it. The request's source only
// goes into a log line, so it is left empty.
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
err := fmt.Errorf("image processing failed: %w: 4097x4096",
imageprocessor.ErrAVIFOutputTooLarge)
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
plain := httptest.NewRecorder()
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err)
for name, rec := range map[string]*httptest.ResponseRecorder{
"respondImageError": plain, "handleImageError": encrypted,
} {
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want %d",
name, rec.Code, http.StatusBadRequest)
}
if !strings.Contains(rec.Body.String(), wantBody) {
t.Errorf("%s: body = %q, want it to say %q",
name, rec.Body.String(), wantBody)
}
}
}
@@ -1,147 +0,0 @@
package handlers
import (
"os"
"path/filepath"
"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/logger"
)
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
// built from cache_max_bytes: omitted, the cache works out the default
// limit; 0 turns the disk cache off; a positive value is the limit,
// unchanged.
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
t.Parallel()
const oneGiB = 1 << 30
cases := []struct {
name string
cacheMaxBytes int64
cacheMaxBytesExplicit bool
wantMaxBytes int64
wantUseDefaultMaxBytes bool
wantDisableDiskCache bool
}{
{
name: "cache_max_bytes omitted",
cacheMaxBytes: 0,
cacheMaxBytesExplicit: false,
wantMaxBytes: 0,
wantUseDefaultMaxBytes: true,
wantDisableDiskCache: false,
},
{
name: "cache_max_bytes: 0",
cacheMaxBytes: 0,
cacheMaxBytesExplicit: true,
wantMaxBytes: 0,
wantUseDefaultMaxBytes: false,
wantDisableDiskCache: true,
},
{
name: "cache_max_bytes: 1 GiB",
cacheMaxBytes: oneGiB,
cacheMaxBytesExplicit: true,
wantMaxBytes: oneGiB,
wantUseDefaultMaxBytes: false,
wantDisableDiskCache: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
CacheMaxBytes: tc.cacheMaxBytes,
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
}
got := newCacheConfig(cfg, nil)
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
if got.MaxBytes != tc.wantMaxBytes {
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
}
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
t.Errorf("UseDefaultMaxBytes = %v, want %v",
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
}
if got.DisableDiskCache != tc.wantDisableDiskCache {
t.Errorf("DisableDiskCache = %v, want %v",
got.DisableDiskCache, tc.wantDisableDiskCache)
}
})
}
}
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
// and checks by whether the cache directories were created that the
// disk cache is on in the first case and off in the second.
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
cacheMaxBytesExplicit bool
wantDiskCache bool
}{
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
CacheMaxBytes: 0,
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
}
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)
}
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
if err != nil {
t.Fatalf("New() error = %v", err)
}
lc.RequireStart()
t.Cleanup(lc.RequireStop)
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
gotDiskCache := err == nil
if gotDiskCache != tc.wantDiskCache {
t.Errorf("cache directories created = %v, want %v",
gotDiskCache, tc.wantDiskCache)
}
})
}
}
@@ -1,61 +0,0 @@
package handlers
import (
"net/http"
"net/netip"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
"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/healthcheck"
"sneak.berlin/go/pixa/internal/logger"
)
// TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided builds the handlers as
// pixad does, in an fx app that provides no fetcher, and requests an image
// from 192.0.2.10, which is on the allowlist and in blocked_networks. The URL
// check accepts that address; only the dialer that refuses internal
// addresses checks blocked_networks, so the answer is 403 only if the
// fetcher the handlers build from the config connects with that dialer. Any
// other dialer would try to connect until the upstream fetch timeout, which
// is short so that the test then fails quickly.
func TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided(t *testing.T) {
t.Parallel()
const host = "192.0.2.10"
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
AllowlistHosts: []string{host},
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
UpstreamFetchTimeout: 2 * time.Second,
// With no connection slots, the fetch would fail before dialing.
UpstreamConnections: config.DefaultUpstreamConnections,
}
var h *Handlers
app := fxtest.New(t,
fx.Supply(cfg),
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New),
fx.Populate(&h),
)
app.RequireStart()
t.Cleanup(app.RequireStop)
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
rec := sendGet(t, r, photoURL(host))
checkErrorBody(t, rec, http.StatusForbidden, "forbidden")
}
-135
View File
@@ -1,135 +0,0 @@
package handlers
import (
"errors"
"fmt"
"mime"
"net/http"
"strconv"
"strings"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Errors for an Accept header that an auto URL cannot be served for.
var (
errInvalidAccept = errors.New("invalid Accept header")
errNotAcceptable = errors.New("not acceptable: auto serves " +
"image/jxl, image/avif, image/webp or image/jpeg")
)
// chooseAutoFormat replaces the format auto in req with the format
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
// response, which then depends on that header. It answers 400 for an Accept
// header that is not valid and 406 for one that allows none of the formats,
// and reports whether req can be served. Any other format is left as it is.
func (s *Handlers) chooseAutoFormat(
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest,
) bool {
if req.Format != imgcache.FormatAuto {
return true
}
w.Header().Add("Vary", "Accept")
format, err := formatForAccept(strings.Join(r.Header.Values("Accept"), ","))
if errors.Is(err, errNotAcceptable) {
s.respondError(w, err.Error(), http.StatusNotAcceptable)
return false
}
if err != nil {
s.respondError(w, err.Error(), http.StatusBadRequest)
return false
}
req.Format = format
return true
}
// formatForAccept returns the format an auto URL is served in for the Accept
// header accept: JPEG XL when it names image/jxl, else AVIF when it names
// image/avif, else WebP when it names image/webp, else JPEG when its most
// specific entry of image/jpeg, image/* and */* allows it, or when it names
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
// clients that cannot show them send image/* and */* too.
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
qualities, err := parseAccept(accept)
if err != nil {
return "", err
}
if len(qualities) == 0 {
return imgcache.FormatJPEG, nil
}
if qualities["image/jxl"] > 0 {
return imgcache.FormatJXL, nil
}
if qualities["image/avif"] > 0 {
return imgcache.FormatAVIF, nil
}
if qualities["image/webp"] > 0 {
return imgcache.FormatWebP, nil
}
// For JPEG, the most specific entry the header has decides
quality, named := qualities["image/jpeg"]
if !named {
quality, named = qualities["image/*"]
}
if !named {
quality = qualities["*/*"]
}
if quality > 0 {
return imgcache.FormatJPEG, nil
}
return "", errNotAcceptable
}
// parseAccept returns the q of each media range the Accept header accept
// names, 1 where it gives none. A media range named more than once keeps its
// lowest q, so a refusal is never overridden. A media range that does not
// parse, or a q that is not a number from 0 to 1, is an error.
func parseAccept(accept string) (map[string]float64, error) {
qualities := make(map[string]float64)
for entry := range strings.SplitSeq(accept, ",") {
// A header field list may hold empty entries
if strings.TrimSpace(entry) == "" {
continue
}
mediaRange, params, err := mime.ParseMediaType(entry)
if err != nil {
return nil, fmt.Errorf("%w: %q: %w", errInvalidAccept, entry, err)
}
quality := 1.0
if qParam, given := params["q"]; given {
quality, err = strconv.ParseFloat(qParam, 64)
inRange := quality >= 0 && quality <= 1
if err != nil || !inRange {
return nil, fmt.Errorf("%w: %q: q is not a number from 0 to 1",
errInvalidAccept, entry)
}
}
previous, named := qualities[mediaRange]
if !named || quality < previous {
qualities[mediaRange] = quality
}
}
return qualities, nil
}
@@ -1,310 +0,0 @@
package handlers
import (
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// The content types the tests below expect.
const (
avifType = "image/avif"
webpType = "image/webp"
jpegType = "image/jpeg"
jsonType = "application/json"
)
// TestFormatForAccept verifies the format chosen for the format auto from each
// Accept header below, and the error for one that allows none of AVIF, WebP
// and JPEG or is not valid.
func TestFormatForAccept(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accept string
want imgcache.ImageFormat
wantErr error
}{
{"AVIF-capable browser",
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
imgcache.FormatAVIF, nil},
{"WebP-capable browser",
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
imgcache.FormatWebP, nil},
{"WebP only", webpType, imgcache.FormatWebP, nil},
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
{"absent", "", imgcache.FormatJPEG, nil},
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
imgcache.FormatJPEG, nil},
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
{"PNG only", "image/png", "", errNotAcceptable},
{"malformed media range", "image/", "", errInvalidAccept},
{"q not a number", "image/avif;q=high", "", errInvalidAccept},
{"q above 1", "image/avif;q=2", "", errInvalidAccept},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := formatForAccept(tt.accept)
t.Logf("Accept %q: %q, %v", tt.accept, got, err)
if got != tt.want || !errors.Is(err, tt.wantErr) {
t.Errorf("formatForAccept(%q) = %q, %v, want %q, %v",
tt.accept, got, err, tt.want, tt.wantErr)
}
})
}
}
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in
// the format auto, made with h's image service and generator.
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAuto,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatAuto,
ExpiresAt: time.Now().Add(time.Minute).Unix(),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
return signedURL, "/v1/e/" + token + "/img.jpg"
}
// requestImage sends method for target to srv with an Accept header line for
// each of accept, and returns the response.
func requestImage(
t *testing.T, srv http.Handler, method, target string, accept ...string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
for _, value := range accept {
req.Header.Add("Accept", value)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
t.Logf("%s %s with Accept %q: %d, Content-Type %s, Vary %v, X-Pixa-Cache %s",
method, target, accept, rec.Code, rec.Header().Get("Content-Type"),
rec.Header().Values("Vary"), rec.Header().Get("X-Pixa-Cache"))
return rec
}
// TestFormatAuto_ChosenFromAccept requests an auto URL on each image route
// with each Accept below, and checks the answer and that it carries
// Vary: Accept. The signed URL is signed for auto, so it is valid whatever
// Accept chooses.
func TestFormatAuto_ChosenFromAccept(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, encryptedURL := autoPhotoURLs(t, h)
tests := []struct {
name string
accept []string
wantStatus int
wantType string
}{
{"AVIF accepted", []string{"image/avif,image/webp,*/*;q=0.8"},
http.StatusOK, avifType},
{"WebP accepted", []string{"image/webp,*/*;q=0.8"}, http.StatusOK, webpType},
{"no Accept", nil, http.StatusOK, jpegType},
{"two Accept lines", []string{"image/png", webpType}, http.StatusOK, webpType},
{"none of the three", []string{"image/gif"},
http.StatusNotAcceptable, jsonType},
{"not valid", []string{"image/avif;q=high"}, http.StatusBadRequest, jsonType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
for _, target := range []string{signedURL, encryptedURL} {
rec := requestImage(t, srv, http.MethodGet, target, tt.accept...)
gotType := rec.Header().Get("Content-Type")
if rec.Code != tt.wantStatus || gotType != tt.wantType {
t.Errorf("%s: %d %s, want %d %s; body %s", target,
rec.Code, gotType, tt.wantStatus, tt.wantType, rec.Body)
}
if !slices.Contains(rec.Header().Values("Vary"), "Accept") {
t.Errorf("%s: Vary = %v, want Accept in it",
target, rec.Header().Values("Vary"))
}
}
})
}
}
// TestFormatAuto_SignatureCoversAuto verifies that a /v1/image/ URL with the
// format auto is checked against a signature for auto, not for the format
// chosen: a URL signed for avif, with auto put in its path, is refused for a
// client whose Accept chooses AVIF.
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAVIF,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
if target == signedForAVIF {
t.Fatalf("no /50x50.avif? in %s", signedForAVIF)
}
rec := requestImage(t, srv, http.MethodGet, target, avifType)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
// TestFormatAuto_CachesEachFormatApart requests an auto URL for AVIF, then
// JPEG, then both again. Each format is processed once and then served from
// the cache, with an ETag of its own, so a client never gets the other format
// from the cache.
func TestFormatAuto_CachesEachFormatApart(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, _ := autoPhotoURLs(t, h)
steps := []struct {
wantType string
wantCache string
}{
{avifType, "MISS"},
{jpegType, "MISS"},
{avifType, "HIT"},
{jpegType, "HIT"},
}
etags := make(map[string]string)
for _, step := range steps {
rec := requestImage(t, srv, http.MethodGet, signedURL, step.wantType)
gotType := rec.Header().Get("Content-Type")
gotCache := rec.Header().Get("X-Pixa-Cache")
if rec.Code != http.StatusOK || gotType != step.wantType ||
gotCache != step.wantCache {
t.Fatalf("Accept %s: %d %s %s, want 200 %s %s", step.wantType,
rec.Code, gotType, gotCache, step.wantType, step.wantCache)
}
etag := rec.Header().Get("ETag")
if previous, seen := etags[gotType]; seen && previous != etag {
t.Errorf("%s ETag changed from %s to %s", gotType, previous, etag)
}
etags[gotType] = etag
}
if etags[avifType] == etags[jpegType] {
t.Errorf("AVIF and JPEG have the same ETag %s", etags[avifType])
}
}
// TestFormatAuto_Vary verifies that on each image route a HEAD answer and a
// 304 for an auto URL carry Vary: Accept, and that the answer for a URL with
// a fixed format does not.
func TestFormatAuto_Vary(t *testing.T) {
t.Parallel()
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
srv := chi.NewRouter()
srv.Get("/v1/image/*", h.HandleImage())
srv.Head("/v1/image/*", h.HandleImage())
srv.Get("/v1/e/{token}/*", h.HandleImageEnc())
srv.Head("/v1/e/{token}/*", h.HandleImageEnc())
signedURL, encryptedURL := autoPhotoURLs(t, h)
for _, urls := range [][2]string{
{signedURL, signedPhotoURL(t, h)},
{encryptedURL, encPhotoURL(t, h)},
} {
autoURL, fixedURL := urls[0], urls[1]
head := requestImage(t, srv, http.MethodHead, autoURL, webpType)
checkVaryAccept(t, head, http.StatusOK, true)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
autoURL, nil)
req.Header.Set("Accept", webpType)
req.Header.Set("If-None-Match", head.Header().Get("ETag"))
notModified := httptest.NewRecorder()
srv.ServeHTTP(notModified, req)
checkVaryAccept(t, notModified, http.StatusNotModified, true)
fixed := requestImage(t, srv, http.MethodGet, fixedURL)
checkVaryAccept(t, fixed, http.StatusOK, false)
}
}
// checkVaryAccept fails the test unless rec answered wantStatus and, as
// wantVary says, has or has not Accept in its Vary header.
func checkVaryAccept(
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantVary bool,
) {
t.Helper()
vary := rec.Header().Values("Vary")
t.Logf("status %d, Vary %v", rec.Code, vary)
if rec.Code != wantStatus {
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
}
if slices.Contains(vary, "Accept") != wantVary {
t.Errorf("Vary = %v, want Accept in it: %v", vary, wantVary)
}
}
+20 -54
View File
@@ -4,13 +4,11 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
@@ -29,11 +27,6 @@ type Params struct {
Healthcheck *healthcheck.Healthcheck
Database *database.Database
Config *config.Config
// Fetcher, when provided, fetches upstream images in place of the
// fetcher the handlers build from the config. Only tests provide one;
// pixad does not.
Fetcher httpfetcher.Fetcher `optional:"true"`
}
// Handlers provides HTTP request handlers.
@@ -42,16 +35,11 @@ type Handlers struct {
hc *healthcheck.Healthcheck
db *database.Database
config *config.Config
fetcher httpfetcher.Fetcher
imgSvc *imgcache.Service
imgCache *imgcache.Cache
sessMgr *session.Manager
encGen *encurl.Generator
csrfProtect func(http.Handler) http.Handler
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
// reports whether a URL's host is on that list.
refererBlocklist *allowlist.HostAllowList
}
// New creates a new Handlers instance.
@@ -62,31 +50,24 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
}
s := &Handlers{
log: params.Logger.Get(),
hc: params.Healthcheck,
db: params.Database,
config: params.Config,
fetcher: params.Fetcher,
csrfProtect: csrfProtect,
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
log: params.Logger.Get(),
hc: params.Healthcheck,
db: params.Database,
config: params.Config,
csrfProtect: csrfProtect,
}
lc.Append(fx.Hook{
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
//nolint:contextcheck // the eviction loop outlives OnStart; OnStop cancels it
OnStart: func(_ context.Context) error {
return s.initImageService()
},
// The pending counts are written here, before the database's stop
// hook closes it.
OnStop: func(ctx context.Context) error {
if s.imgCache == nil {
return nil
}
return errors.Join(
s.imgCache.StopEviction(ctx),
s.imgCache.StopPendingCountWrites(ctx),
)
return s.imgCache.StopEviction(ctx)
},
})
@@ -99,24 +80,18 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
return s.imgSvc.WaitForProcessing(ctx)
}
// newCacheConfig builds the image cache's configuration from cfg.
// cache_max_bytes: 0 disables the disk cache entirely; any other value
// is the eviction limit in bytes; when it is omitted, the cache works
// out the default limit itself.
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
return imgcache.CacheConfig{
StateDir: cfg.StateDir,
NegativeTTL: imgcache.DefaultNegativeTTL,
MaxBytes: cfg.CacheMaxBytes,
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
Logger: log,
}
}
// initImageService initializes the image cache and service.
func (s *Handlers) initImageService() error {
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
// 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{
StateDir: s.config.StateDir,
CacheTTL: imgcache.DefaultCacheTTL,
NegativeTTL: imgcache.DefaultNegativeTTL,
MaxBytes: s.config.CacheMaxBytes,
DisableDiskCache: s.config.CacheMaxBytes == 0,
Logger: s.log,
})
if err != nil {
return err
}
@@ -124,13 +99,9 @@ func (s *Handlers) initImageService() error {
s.imgCache = cache
// Background eviction: startup reconciliation, then periodic and
// write-pressure passes, and the removal of expired negative cache
// rows, which is all it does when the disk cache is disabled.
// write-pressure passes. No-op when the disk cache is disabled.
cache.StartEviction(imgcache.DefaultEvictionInterval)
// Writes the counts requests could not write by their deadline
cache.StartPendingCountWrites()
// Create the fetcher config
fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP
@@ -144,12 +115,10 @@ func (s *Handlers) initImageService() error {
fetcherCfg.MaxConnections = s.config.UpstreamConnections
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
// Create the service. With no fetcher provided, it builds its own from
// fetcherCfg.
// Create the service
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
FetcherConfig: fetcherCfg,
Fetcher: s.fetcher,
SigningKey: s.config.SigningKey,
Allowlist: s.config.AllowlistHosts,
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
@@ -164,10 +133,7 @@ func (s *Handlers) initImageService() error {
// Initialize session manager (signing key is validated at config load
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
// The random value that each logout replaces is saved in the database,
// so it outlasts a restart.
sessMgr, err := session.NewManagerWithDatabase(
context.Background(), s.config.SigningKey, s.db.DB())
sessMgr, err := session.NewManager(s.config.SigningKey)
if err != nil {
return err
}
@@ -47,6 +47,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
+10 -72
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
@@ -17,14 +18,9 @@ import (
)
// HandleImage handles the main image proxy route:
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
// /v1/image/<host>/<path>/<width>x<height>
// /v1/image/<host>/<path>/<width>x<height>.<format>
func (s *Handlers) HandleImage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.refuseBlockedReferer(w, r) {
return
}
req, ok := s.parseImageRequest(w, r)
if !ok {
return
@@ -43,11 +39,6 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
return
}
// The signature covers the format auto, not the format chosen
if !s.chooseAutoFormat(w, r, req) {
return
}
// Get cache key for logging
cacheKey := imgcache.CacheKey(req)
@@ -79,21 +70,20 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
}
}
// parseImageRequest parses the image URL's path and query parameters into
// 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) {
// The path is parsed decoded once, as r.URL.Path holds it, and not from
// chi's wildcard: chi routes on the path as the client wrote it whenever
// that is not Go's own encoding of it, so its wildcard would still hold
// the %3F before a source query when, say, a "/" in the query is written
// as %2F
parsed, err := imgcache.ParseImageURL(r.URL.Path)
// 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", r.URL.Path,
"path", pathParam,
"error", err,
)
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
@@ -206,16 +196,6 @@ func parseExpires(query url.Values) (time.Time, error) {
return time.Unix(exp, 0), nil
}
// HandleTooManyRequests answers 429 Too Many Requests with the JSON error
// body the image routes send for their other errors. The image routes' rate
// limit calls it for a request over the limit.
func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
s.respondError(w, "too many requests, try again later",
http.StatusTooManyRequests)
}
}
// respondImageError maps image retrieval errors to HTTP responses.
func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
@@ -240,38 +220,13 @@ func (s *Handlers) respondImageError(
}
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
errors.Is(err, imageprocessor.ErrTooManyImages) ||
errors.Is(err, imgcache.ErrProcessingTimedOut) {
errors.Is(err, imageprocessor.ErrTooManyImages) {
s.respondError(w, "server busy, try again later",
http.StatusServiceUnavailable)
return
}
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
return
}
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
"computed from its proportions is above %d",
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
return
}
if errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge) {
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
http.StatusBadRequest)
return
}
s.respondError(w, "internal error", http.StatusInternalServerError)
}
@@ -293,23 +248,6 @@ func cacheControl(expires time.Time) string {
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
}
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
// names a host on referer_blocklist, and reports whether it answered. A request
// with no Referer, or one that does not parse as a URL with a host, is not
// refused.
func (s *Handlers) refuseBlockedReferer(
w http.ResponseWriter, r *http.Request,
) bool {
referer, err := url.Parse(r.Referer())
if err != nil || !s.refererBlocklist.IsAllowed(referer) {
return false
}
s.respondError(w, "referer blocked", http.StatusForbidden)
return true
}
// notModified sets the ETag header to etag and, when the request's
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
// answered. An empty etag sets no header and never answers.
@@ -31,6 +31,7 @@ func newSignedHostServer(
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
@@ -29,6 +29,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
@@ -169,166 +169,3 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
})
}
}
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
// by the service's signed-URL generator for source URLs with a query to the
// route, through a chi router: chi routes on the path as the client wrote it
// whenever that is not Go's own encoding of it. A 502 means the signature
// passed, so the route read the source path and query that were signed.
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
t.Parallel()
tests := []struct {
name string
query string
}{
{"slash and colon", "url=https://origin.example.com/cat.jpg"},
{"semicolon", "a=1;b=2"},
{"comma", "crop=0,0,50,50"},
{"equals and ampersand", "a=1&b=2"},
{"percent-escape", "name=my%20cat"},
{"question mark", "next=/a?b=1"},
}
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: photoPath,
SourceQuery: tt.query,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, 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_SourceQueryEncodedAnyWay signs one source URL with a query
// and writes it into the image URL in ways the README allows: the "?" before
// the query and each "%" in it percent-encoded, and every other character as
// itself or percent-encoded. A 502 means the signature passed.
func TestHandleImage_SourceQueryEncodedAnyWay(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: photoPath,
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
tests := []struct {
name string
writtenQuery string
}{
{"only ? and % encoded",
"%3Furl=https://origin.example.com/my%2520cat.jpg"},
{"every reserved character encoded",
"%3Furl%3Dhttps%3A%2F%2Forigin.example.com%2Fmy%2520cat.jpg"},
{"lower-case hex digits",
"%3furl=https://origin.example.com/my%2520cat.jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
target := fmt.Sprintf("/v1/image/%s%s%s/50x50.jpeg?sig=%s&exp=%d",
signedHost, photoPath, tt.writtenQuery, sig, expires.Unix())
status := getImage(t, setupTestHandler(t), target)
if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
}
})
}
}
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
// source query that each hold %25, the percent-escape of a literal "%", and
// sends each to the route written as the README says, with that "%" written as
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed.
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sourcePath string
sourceQuery string
// writtenPathAndQuery is the source path and query as the README says
// to write them into the image URL
writtenPathAndQuery string
}{
{"path", "/sale/50%25off.jpg", "", "/sale/50%2525off.jpg"},
{"query", photoPath, "discount=50%25", photoPath + "%3Fdiscount=50%2525"},
}
for _, tt := range tests {
t.Run(tt.name+" written by the README rule", func(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: tt.sourcePath,
SourceQuery: tt.sourceQuery,
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
status := getImage(t, setupTestHandler(t), target)
if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
}
})
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: tt.sourcePath,
SourceQuery: tt.sourceQuery,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, 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)
}
})
}
}
+2 -19
View File
@@ -22,15 +22,11 @@ import (
// browsers identify the content type.
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.refuseBlockedReferer(w, r) {
return
}
ctx := r.Context()
start := time.Now()
req, ok := s.parseImageEncRequest(w, r)
if !ok || !s.chooseAutoFormat(w, r, req) {
if !ok {
return
}
@@ -158,22 +154,9 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
case errors.Is(err, httpfetcher.ErrTooManyConnections),
errors.Is(err, imageprocessor.ErrTooManyImages),
errors.Is(err, imgcache.ErrProcessingTimedOut):
errors.Is(err, imageprocessor.ErrTooManyImages):
s.respondError(w, "server busy, try again later",
http.StatusServiceUnavailable)
case errors.Is(err, httpfetcher.ErrInvalidContentType),
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
"computed from its proportions is above %d",
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
http.StatusBadRequest)
default:
s.log.Error("image request failed", "error", err)
s.respondError(w, "internal error", http.StatusInternalServerError)
@@ -1,126 +0,0 @@
package handlers
import (
"image/jpeg"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"sneak.berlin/go/pixa/internal/encurl"
)
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
// on signedHost at the 50x50 that encPhotoURL and the generator tests ask for.
func requireServedPhoto(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body %q",
rec.Code, http.StatusOK, rec.Body.String())
}
contentType := rec.Header().Get("Content-Type")
if contentType != "image/jpeg" {
t.Errorf("Content-Type = %q, want image/jpeg", contentType)
}
img, err := jpeg.DecodeConfig(rec.Body)
if err != nil {
t.Fatalf("body is not a JPEG: %v", err)
}
if img.Width != 50 || img.Height != 50 {
t.Errorf("image is %dx%d, want 50x50", img.Width, img.Height)
}
}
// TestHandleImageEnc_ValidToken_ServesImage verifies that a token made with
// the signing key serves the image it asks for.
func TestHandleImageEnc_ValidToken_ServesImage(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, encPhotoURL(t, h), nil))
requireServedPhoto(t, rec)
}
// TestHandleImageEnc_RejectedToken verifies that a token that has expired
// answers 410, and that a token with one character changed, a token cut
// short, and a token made with another signing key answer 400. The server
// would serve the photo for a token it accepted.
func TestHandleImageEnc_RejectedToken(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
photo := encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
}
valid, err := h.encGen.Generate(&photo)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
expiredPhoto := photo
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
expired, err := h.encGen.Generate(&expiredPhoto)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
otherKey, err := otherGen.Generate(&photo)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
// Changing a character in the middle always changes the decoded bytes;
// the last character of unpadded base64 can carry unused bits.
middle := len(valid) / 2
replacement := "A"
if valid[middle] == 'A' {
replacement = "B"
}
changed := valid[:middle] + replacement + valid[middle+1:]
tests := []struct {
name string
token string
wantStatus int
}{
{"expired", expired, http.StatusGone},
{"one character changed", changed, http.StatusBadRequest},
{"cut short", valid[:middle], http.StatusBadRequest},
{"another signing key", otherKey, http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := getEncToken(srv, tt.token)
t.Logf("GET /v1/e/%s/img.jpg: %d %s", tt.token, rec.Code, rec.Body)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}
@@ -1,162 +0,0 @@
package handlers
import (
"fmt"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature"
)
// requireJPEGXL requires that rec answers 200 with a JPEG XL image.
func requireJPEGXL(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body %q",
rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != jxlType {
t.Errorf("Content-Type = %q, want %s", got, jxlType)
}
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
t.Errorf("body is %s, want jxl", vips.ImageTypes[got])
}
}
// TestImageWithoutFormat_ServesJPEGXL verifies that a /v1/image/ URL whose
// last segment is a size with no format, 50x50 or orig, answers JPEG XL.
func TestImageWithoutFormat_ServesJPEGXL(t *testing.T) {
t.Parallel()
route := newImageRoute(t, newPhotoFetcher(t, allowlistedHost))
for _, size := range []string{"50x50", "orig"} {
target := "/v1/image/" + allowlistedHost + photoPath + "/" + size
requireJPEGXL(t, sendGet(t, route, target))
}
}
// TestImageWithoutFormat_SignedAsJXL verifies that a /v1/image/ URL with no
// format is signed as jxl: the signature made for the URL ending in .jxl is
// accepted for the same URL without .jxl.
func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
t.Parallel()
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
expires := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: string(imgcache.FormatJXL),
Quality: encurl.DefaultQuality,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
for _, size := range []string{"50x50.jxl", "50x50"} {
target := "/v1/image/" + signedHost + photoPath + "/" + size + query
requireJPEGXL(t, sendGet(t, route, target))
}
}
// TestImageEncWithoutFormat_ServesJPEGXL verifies that an encrypted URL whose
// token holds no format answers JPEG XL.
func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
requireJPEGXL(t, getEncToken(srv, token))
}
// TestGeneratorPage_SelectsJPEGXL verifies that the generator page's format
// choice is JPEG XL until another is chosen.
func TestGeneratorPage_SelectsJPEGXL(t *testing.T) {
t.Parallel()
h, srv := newCSRFTestRouter(t)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(newSessionCookie(t, h))
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `<option value="jxl" selected>`) {
t.Errorf("generator page does not select JPEG XL: %s", rec.Body.String())
}
}
// TestGeneratePost_NoFormat_MakesJPEGXLURL verifies that the generator form
// sent with an empty format field, or with none, makes a URL whose name ends
// in .jxl and which answers JPEG XL.
func TestGeneratePost_NoFormat_MakesJPEGXLURL(t *testing.T) {
t.Parallel()
photo := url.Values{
sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"},
heightField: {"50"},
}
emptyFormat := maps.Clone(photo)
emptyFormat.Set(formatField, "")
for name, form := range map[string]url.Values{
"empty format field": emptyFormat,
"no format field": photo,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
rec := generatePost(t, form)
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
if match == nil {
t.Fatalf("generator page shows no URL: %d %s",
rec.Code, rec.Body.String())
}
t.Logf("generated URL path: %s", match[1])
if !strings.HasSuffix(match[1], "/img.jxl") {
t.Errorf("generated URL %s does not end in /img.jxl", match[1])
}
imageRec := httptest.NewRecorder()
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, match[1], nil))
requireJPEGXL(t, imageRec)
})
}
}
-73
View File
@@ -1,73 +0,0 @@
package handlers
import (
"log/slog"
"net/http"
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imgcache"
)
// jxlType is the content type of JPEG XL.
const jxlType = "image/jxl"
// TestFormatForAccept_JPEGXL verifies that the format auto chooses JPEG XL
// when Accept names image/jxl, ahead of AVIF whatever their q, and AVIF when
// Accept refuses JPEG XL with q=0.
func TestFormatForAccept_JPEGXL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accept string
want imgcache.ImageFormat
}{
{"JPEG XL-capable browser",
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
{"JPEG XL only", jxlType, imgcache.FormatJXL},
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
imgcache.FormatJXL},
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := formatForAccept(tt.accept)
if got != tt.want || err != nil {
t.Errorf("formatForAccept(%q) = %q, %v, want %q",
tt.accept, got, err, tt.want)
}
})
}
}
// TestFormatAuto_JPEGXL requests an auto URL on each image route with an
// Accept header that names image/jxl, and checks that the answer is a JPEG XL
// image.
func TestFormatAuto_JPEGXL(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, encryptedURL := autoPhotoURLs(t, h)
for _, target := range []string{signedURL, encryptedURL} {
rec := requestImage(t, srv, http.MethodGet, target,
"image/jxl,image/avif,image/webp,*/*;q=0.8")
gotType := rec.Header().Get("Content-Type")
if rec.Code != http.StatusOK || gotType != jxlType {
t.Errorf("%s: %d %s, want 200 %s; body %s",
target, rec.Code, gotType, jxlType, rec.Body)
continue
}
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
t.Errorf("%s: body is %s, want jxl", target, vips.ImageTypes[got])
}
}
}
@@ -1,184 +0,0 @@
package handlers
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
)
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
// referer_blocklist.
const blockedReferer = "https://leech.example/page.html"
// countingFetcher passes each fetch on to the fetcher it holds and counts it.
type countingFetcher struct {
httpfetcher.Fetcher
fetches atomic.Int32
}
// Fetch counts the fetch and passes it on.
func (f *countingFetcher) Fetch(
ctx context.Context, url string,
) (*httpfetcher.FetchResult, error) {
f.fetches.Add(1)
return f.Fetcher.Fetch(ctx, url)
}
// newRefererRoutes returns both image routes of a Handlers whose
// referer_blocklist is "leech.example" and ".hotlinker.example", the
// Handlers, and the fetcher the routes fetch through. The JPEG at photoPath
// exists on allowlistedHost and on signedHost.
func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher) {
t.Helper()
fetcher := &countingFetcher{
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
}
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: fetcher,
SigningKey: testSigningKey,
Allowlist: []string{allowlistedHost},
})
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,
refererBlocklist: allowlist.New(
[]string{"leech.example", ".hotlinker.example"}),
}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return r, h, fetcher
}
// getWithReferer sends a GET for target to routes with referer as its
// Referer header, or with none when referer is empty, and returns the
// response.
func getWithReferer(
t *testing.T, routes http.Handler, target, referer string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
if referer != "" {
req.Header.Set("Referer", referer)
}
rec := httptest.NewRecorder()
routes.ServeHTTP(rec, req)
t.Logf("GET %s with Referer %q: %d", target, referer, rec.Code)
return rec
}
// TestRefererBlocklist verifies that both image routes refuse a request whose
// Referer names a host on referer_blocklist with 403 and the JSON error,
// without fetching from the upstream host, and serve a request with no
// Referer, one that does not parse, or one naming any other host. Hosts are
// matched as allowlist_hosts matches them.
func TestRefererBlocklist(t *testing.T) {
t.Parallel()
cases := []struct {
name string
referer string
want int
}{
{"no referer", "", http.StatusOK},
{"unlisted host", "https://unlisted.example/page.html", http.StatusOK},
{"unparseable", "%zz", http.StatusOK},
{"listed host", blockedReferer, http.StatusForbidden},
{"subdomain of listed host", "https://www.leech.example/", http.StatusOK},
{"subdomain of dot pattern", "https://www.hotlinker.example/a.html",
http.StatusForbidden},
{"dot pattern without its dot", "https://hotlinker.example/",
http.StatusForbidden},
{"host continuing past dot pattern",
"https://hotlinker.example.evil.example/", http.StatusOK},
}
// The photo's URL on each image route.
photoURLs := map[string]func(t *testing.T, h *Handlers) string{
"plain URL": func(t *testing.T, _ *Handlers) string {
t.Helper()
return photoURL(allowlistedHost)
},
"encrypted URL": encPhotoURL,
}
for urlName, photoURLFor := range photoURLs {
for _, tc := range cases {
t.Run(urlName+", "+tc.name, func(t *testing.T) {
t.Parallel()
routes, h, fetcher := newRefererRoutes(t)
rec := getWithReferer(t, routes, photoURLFor(t, h), tc.referer)
if tc.want == http.StatusOK {
requireServedPhoto(t, rec)
return
}
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
if n := fetcher.fetches.Load(); n != 0 {
t.Errorf("upstream fetched %d times, want 0", n)
}
})
}
}
}
// TestBlockedRefererRefusedWhenImageIsCached verifies that a request whose
// Referer is on referer_blocklist is refused even when the image it asks for
// is already cached, so the answer does not depend on the cache.
func TestBlockedRefererRefusedWhenImageIsCached(t *testing.T) {
t.Parallel()
routes, h, _ := newRefererRoutes(t)
for _, target := range []string{photoURL(allowlistedHost), encPhotoURL(t, h)} {
requireServedPhoto(t, getWithReferer(t, routes, target, ""))
rec := getWithReferer(t, routes, target, blockedReferer)
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
}
}
@@ -1,7 +1,6 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"net/http"
@@ -48,40 +47,3 @@ func TestServerBusyAnswers503(t *testing.T) {
}
}
}
// TestProcessingTimedOutAnswers503 checks that respondImageError and
// handleImageError, which answer the errors of the two image routes, answer
// 503 with the server busy error when the image service reports that
// processing stopped at the deadline of the request doing it, wrapped as the
// service wraps it. A request that waited for that processing gets this error
// while its own deadline may have time left. The request's source only goes
// into a log line, so it is left empty.
func TestProcessingTimedOutAnswers503(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
err := fmt.Errorf("%w: failed to encode: %w",
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
plain := httptest.NewRecorder()
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err)
for name, rec := range map[string]*httptest.ResponseRecorder{
"respondImageError": plain, "handleImageError": encrypted,
} {
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("%s: status = %d, want %d",
name, rec.Code, http.StatusServiceUnavailable)
}
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
t.Errorf("%s: body = %q, want the server busy error",
name, rec.Body.String())
}
}
}
@@ -1,54 +0,0 @@
package handlers
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
// 415 with a clear error when the upstream answers with a content type pixa
// does not accept, such as SVG, and when orig is asked of a source in a
// format the processor cannot name, wrapped as the service wraps them.
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"}
for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
httpfetcher.ErrInvalidContentType),
fmt.Errorf("image processing failed: %w: unknown",
imageprocessor.ErrUnsupportedInputFormat),
} {
plain := httptest.NewRecorder()
h.respondImageError(plain, req, err)
encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err)
for route, rec := range map[string]*httptest.ResponseRecorder{
"HandleImage": plain, "HandleImageEnc": encrypted,
} {
t.Logf("%s for %v: %d %s", route, err, rec.Code, rec.Body.String())
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("%s for %v: status = %d, want %d",
route, err, rec.Code, http.StatusUnsupportedMediaType)
}
if !strings.Contains(rec.Body.String(), "unsupported source format") {
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
route, err, rec.Body.String())
}
}
}
}
@@ -1,66 +0,0 @@
package httpfetcher
import (
"errors"
"net"
"testing"
)
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
// without DialContext, as pixa builds it, refuses to connect to a local
// server.
func TestNewUsesCheckedDialerWithoutDialContext(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
transport := transportOf(t, New(DefaultConfig()))
addr := srv.Listener.Addr().String()
_, err := transport.DialContext(testContext(t), "tcp", addr)
if !errors.Is(err, ErrSSRFBlocked) {
t.Fatalf("DialContext(%s) error = %v, want ErrSSRFBlocked", addr, err)
}
}
// TestDialContextReplacesOnlyTheDialer checks that a fetcher built with
// DialContext connects through it, while the URL check still refuses a
// loopback URL and the redirect check a redirect to a link-local address.
func TestDialContextReplacesOnlyTheDialer(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
cfg := DefaultConfig()
cfg.AllowHTTP = true
cfg.DialContext = dialer.dialContext
f := New(cfg)
if body := fetchBody(t, f, "/image"); body != imagePayload {
t.Errorf("body = %q, want %q", body, imagePayload)
}
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
}
_, err = f.Fetch(testContext(t), upstreamURL("/redirect/private"))
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
}
// The upstream server is reached through DialContext, and nothing else
// is asked of it.
dialed := dialer.dialedAddrs()
if len(dialed) == 0 {
t.Error("DialContext was never called")
}
for _, addr := range dialed {
if addr != net.JoinHostPort(testPublicHost, "80") {
t.Errorf("DialContext was asked to connect to %s", addr)
}
}
}
-134
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"sync"
"testing"
@@ -298,139 +297,6 @@ func TestFetchMaxResponseSizeEnforced(t *testing.T) {
}
}
// fetchWithMaxResponseSize serves handler as the fake upstream and fetches
// its /image route with MaxResponseSize set to maxResponseSize.
func fetchWithMaxResponseSize(
t *testing.T,
maxResponseSize int64,
handler http.HandlerFunc,
) *FetchResult {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
cfg := DefaultConfig()
cfg.MaxResponseSize = maxResponseSize
f, _ := newServerFetcher(t, srv, cfg)
res := fetchImage(t, f, "/image")
t.Cleanup(func() { _ = res.Content.Close() })
return res
}
// TestFetchContentLengthBodyAtMaxResponseSize checks that a body sent with a
// Content-Length is read whole at exactly MaxResponseSize bytes and refused
// at one byte more.
func TestFetchContentLengthBodyAtMaxResponseSize(t *testing.T) {
t.Parallel()
const maxResponseSize = 8
for _, tc := range []struct {
size int
wantErr error
}{
{maxResponseSize, nil},
{maxResponseSize + 1, ErrResponseTooLarge},
} {
t.Run(strconv.Itoa(tc.size)+" bytes", func(t *testing.T) {
t.Parallel()
body := strings.Repeat("x", tc.size)
res := fetchWithMaxResponseSize(t, maxResponseSize,
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentTypeJPEG)
w.Header().Set("Content-Length", strconv.Itoa(tc.size))
_, _ = io.WriteString(w, body)
})
if res.ContentLength != int64(tc.size) {
t.Fatalf("ContentLength = %d, want %d", res.ContentLength, tc.size)
}
data, err := io.ReadAll(res.Content)
t.Logf("body of %d bytes: read %d, error %v", tc.size, len(data), err)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("read error = %v, want %v", err, tc.wantErr)
}
if err == nil && string(data) != body {
t.Errorf("read %q, want %q", data, body)
}
})
}
}
// TestFetchChunkedBodyAtMaxResponseSize checks that a chunked body is read
// whole at exactly MaxResponseSize bytes and refused at one byte more. The
// upstream sends the end of the body only once the test has read
// MaxResponseSize bytes, so the read after them comes before the end of the
// body has arrived, as it can from a real upstream.
func TestFetchChunkedBodyAtMaxResponseSize(t *testing.T) {
t.Parallel()
const maxResponseSize = 8
for _, tc := range []struct {
size int
wantErr error
}{
{maxResponseSize, nil},
{maxResponseSize + 1, ErrResponseTooLarge},
} {
t.Run(strconv.Itoa(tc.size)+" bytes", func(t *testing.T) {
t.Parallel()
body := strings.Repeat("x", tc.size)
endBody := make(chan struct{})
res := fetchWithMaxResponseSize(t, maxResponseSize,
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentTypeJPEG)
_, _ = io.WriteString(w, body)
// With no Content-Length, flushing sends the body
// chunked; its end is sent when the handler returns.
_ = http.NewResponseController(w).Flush()
select {
case <-endBody:
case <-r.Context().Done():
}
})
if res.ContentLength != -1 {
t.Fatalf("ContentLength = %d, want -1 (chunked)", res.ContentLength)
}
head := make([]byte, maxResponseSize)
_, err := io.ReadFull(res.Content, head)
if err != nil {
t.Fatalf("read the first %d bytes: %v", maxResponseSize, err)
}
close(endBody)
rest, err := io.ReadAll(res.Content)
data := string(head) + string(rest)
t.Logf("body of %d bytes: read %d, error %v", tc.size, len(data), err)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("read error = %v, want %v", err, tc.wantErr)
}
if err == nil && data != body {
t.Errorf("read %q, want %q", data, body)
}
})
}
}
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
t.Parallel()
+9 -40
View File
@@ -45,7 +45,6 @@ const (
contentTypeGIF = "image/gif"
contentTypeWebP = "image/webp"
contentTypeAVIF = "image/avif"
contentTypeJXL = "image/jxl"
contentTypeSVG = "image/svg+xml"
contentTypeOctetStream = "application/octet-stream"
)
@@ -138,11 +137,6 @@ type Config struct {
// BlockedNetworks are operator-supplied CIDR ranges refused by the
// dialer, in addition to the always-enforced built-in ranges.
BlockedNetworks []netip.Prefix
// DialContext, when set, makes the fetcher's connections in place of
// the dialer that refuses internal addresses; the URL and redirect
// checks still run. Only tests set it, to reach a local server; the
// config file and the environment cannot.
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
}
// DefaultConfig returns a Config with sensible defaults.
@@ -157,7 +151,7 @@ func DefaultConfig() *Config {
contentTypeGIF,
contentTypeWebP,
contentTypeAVIF,
contentTypeJXL,
contentTypeSVG,
},
AllowHTTP: false,
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
@@ -196,19 +190,13 @@ func New(config *Config) *HTTPFetcher {
config = DefaultConfig()
}
// Unless config.DialContext replaces it, the transport connects with
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
// (closing the DNS-rebinding window) against both the built-in ranges
// and the operator-supplied blocklist.
dialContext := config.DialContext
if dialContext == nil {
dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
}
}
// 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: dialContext,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
},
TLSHandshakeTimeout: DefaultTLSTimeout,
MaxIdleConns: DefaultMaxIdleConns,
IdleConnTimeout: DefaultIdleConnTimeout,
@@ -660,33 +648,14 @@ func dialSSRFSafe(
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
}
// limitedReader wraps a reader and refuses a body longer than remaining
// bytes with ErrResponseTooLarge. remaining goes below 0 once the body is
// found to be longer.
// limitedReader wraps a reader and limits the number of bytes read.
type limitedReader struct {
reader io.Reader
remaining int64
}
// Read reads at most the remaining bytes. Once they are read, it reads one
// more byte to tell the end of the body from more data: at the end it
// returns the wrapped reader's error, io.EOF; with more data it returns
// ErrResponseTooLarge, as does every later Read.
func (r *limitedReader) Read(p []byte) (int, error) {
if r.remaining < 0 {
return 0, ErrResponseTooLarge
}
if r.remaining == 0 {
var extra [1]byte
n, err := r.reader.Read(extra[:])
r.remaining -= int64(n)
if n == 0 {
return 0, err
}
if r.remaining <= 0 {
return 0, ErrResponseTooLarge
}
@@ -1,20 +0,0 @@
package httpfetcher
import "testing"
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
// as image/jxl.
func TestJPEGXLContentType(t *testing.T) {
t.Parallel()
const jxlType = "image/jxl"
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
}
if got := detectContentTypeFromPath("images/photo.jxl"); got != jxlType {
t.Errorf("detectContentTypeFromPath(photo.jxl) = %q, want %q", got, jxlType)
}
}
-2
View File
@@ -109,8 +109,6 @@ func detectContentTypeFromPath(path string) string {
return contentTypeWebP
case strings.HasSuffix(path, ".avif"):
return contentTypeAVIF
case strings.HasSuffix(path, ".jxl"):
return contentTypeJXL
case strings.HasSuffix(path, ".svg"):
return contentTypeSVG
default:
@@ -1,64 +0,0 @@
package imageprocessor
import (
"bytes"
"errors"
"testing"
)
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
// of more than MaxAVIFOutputPixels is refused with ErrAVIFOutputTooLarge
// before anything is decoded, whatever size and fit give it, and that an AVIF
// output of at most that many pixels, or one in another format, is not. The
// input is a 64x32 PNG cut off in its pixel data: libvips reads its size from
// its header but fails when it decodes it, so a request that gets that failure
// was not refused, and a refusal came before any decoding.
func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
t.Parallel()
whole := createTestPNG(t, 64, 32)
cutOff := whole[:len(whole)/2]
tests := []struct {
name string
format Format
width, height int
fit FitMode
refused bool
}{
{"AVIF at the limit", FormatAVIF, 4096, 4096, FitCover, false},
{"AVIF above the limit", FormatAVIF, 4096, 4097, FitCover, true},
{"WebP above the AVIF limit", FormatWebP, 4096, 4097, FitCover, false},
// FitInside does not enlarge, so the output stays 64x32
{"AVIF inside a larger size", FormatAVIF, 8192, 8192, FitInside, false},
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
{"AVIF outside a smaller size", FormatAVIF, 8192, 1, FitOutside, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := New(Params{}).Process(t.Context(), bytes.NewReader(cutOff),
&Request{
Size: Size{Width: tt.width, Height: tt.height},
Format: tt.format,
FitMode: tt.fit,
})
t.Logf("Process() error = %v", err)
if tt.refused {
if !errors.Is(err, ErrAVIFOutputTooLarge) {
t.Errorf("Process() error = %v, want %v", err, ErrAVIFOutputTooLarge)
}
return
}
if err == nil || errors.Is(err, ErrAVIFOutputTooLarge) {
t.Errorf("Process() error = %v, want the failure to decode "+
"the cut-off input", err)
}
})
}
}
@@ -1,52 +0,0 @@
package imageprocessor
import (
"bytes"
"errors"
"testing"
)
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
// with one side 0, which Process computes from the input's proportions, is
// served when that side comes out at MaxOutputDimension and refused with
// ErrOutputTooLarge when it would be above. A 4096x1 or 1x4096 input given
// 2 for its short side has 8192 for its long side.
func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputWidth, inputHeight int
size Size
wantErr error
}{
{"width at the maximum", 4096, 1, Size{Height: 2}, nil},
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge},
{"height at the maximum", 1, 4096, Size{Width: 2}, nil},
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
if result != nil {
t.Logf("Process() made %dx%d", result.Width, result.Height)
if max(result.Width, result.Height) != MaxOutputDimension {
t.Errorf("Process() made %dx%d, want a side of %d",
result.Width, result.Height, MaxOutputDimension)
}
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("Process() error = %v, want %v", err, tt.wantErr)
}
})
}
}
@@ -1,106 +0,0 @@
package imageprocessor
import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
)
// readerAfterDeadline is a Process input whose first Read waits until done
// is closed, so the deadline passes after Process has taken its slot and
// before it encodes.
type readerAfterDeadline struct {
data *bytes.Reader
done <-chan struct{}
waited bool
}
func (r *readerAfterDeadline) Read(p []byte) (int, error) {
if !r.waited {
r.waited = true
<-r.done
}
return r.data.Read(p)
}
// inputNotToBeRead is a Process input that fails the test when it is read.
type inputNotToBeRead struct {
t *testing.T
}
func (r inputNotToBeRead) Read([]byte) (int, error) {
r.t.Error("Process read its input although its deadline had passed")
return 0, io.EOF
}
// TestProcessStopsWhenDeadlinePassed checks that Process returns
// context.DeadlineExceeded, and holds no slot afterwards, when its context's
// deadline has passed before it starts, then without reading its input, or
// while it reads its input. govips gives pixa no way to stop libvips once an
// encode has begun, so Process must not begin one for a request that has
// ended.
func TestProcessStopsWhenDeadlinePassed(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 50, 50)
t.Run("before Process starts", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithDeadline(t.Context(),
time.Now().Add(-time.Second))
defer cancel()
// With a slot free, the select in acquireSlot takes the slot or sees
// the ended context at random, so Process is called many times
for range 100 {
checkProcessStopsAtDeadline(ctx, t, inputNotToBeRead{t: t})
}
})
t.Run("while Process reads its input", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
checkProcessStopsAtDeadline(ctx, t, &readerAfterDeadline{
data: bytes.NewReader(input), done: ctx.Done(),
})
})
}
// checkProcessStopsAtDeadline runs Process on input with ctx, whose deadline
// has passed by the time Process could encode, and fails the test unless
// Process returns context.DeadlineExceeded and gives its slot back.
func checkProcessStopsAtDeadline(
ctx context.Context, t *testing.T, input io.Reader,
) {
t.Helper()
proc := New(Params{MaxConcurrentProcessing: 1})
result, err := proc.Process(ctx, input, smallJPEGRequest())
if result != nil {
t.Logf("Process() made a %dx%d image after its deadline passed",
result.Width, result.Height)
_ = result.Content.Close()
}
t.Logf("Process() error = %v", err)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("Process() error = %v, want context.DeadlineExceeded", err)
}
if held := len(proc.processingSemaphore); held != 0 {
t.Errorf("%d slots still held after Process() returned", held)
}
}
@@ -1,21 +0,0 @@
package imageprocessor
import (
"bytes"
"errors"
"testing"
)
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
// is refused, as both image routes give every request a format before it is
// processed.
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
t.Parallel()
_, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{},
)
if !errors.Is(err, ErrUnsupportedOutputFormat) {
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
}
}
@@ -1,171 +0,0 @@
package imageprocessor
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
// processedSize runs input through Process and returns the output's size in
// bytes.
func processedSize(t *testing.T, input []byte, req *Request) int64 {
t.Helper()
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
_ = result.Content.Close()
return result.ContentLength
}
// encodePNG encodes img as PNG with Go's encoder.
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
// decode decodes input with vips, as Process does.
func decode(t *testing.T, input []byte) *vips.ImageRef {
t.Helper()
img, err := vips.NewImageFromBuffer(input)
if err != nil {
t.Fatalf("failed to decode input: %v", err)
}
t.Cleanup(img.Close)
return img
}
// TestImageProcessor_PNGIsCompressed verifies that a PNG output is
// compressed: a flat 200x150 image, 90,000 bytes of raw pixels, comes out at
// a small fraction of that.
func TestImageProcessor_PNGIsCompressed(t *testing.T) {
t.Parallel()
const width, height = 200, 150
flat := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
flat.Set(x, y, color.RGBA{R: 40, G: 120, B: 200, A: 255})
}
}
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
const rawBytes = width * height * 3
if size > rawBytes/10 {
t.Errorf("PNG output is %d bytes, want under a tenth of its %d raw",
size, rawBytes)
}
}
// TestImageProcessor_WebPAtEffort2 verifies that WebP is saved at effort 2,
// not libvips' default of 4: the output is the size of the same image saved
// at effort 2.
func TestImageProcessor_WebPAtEffort2(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 200, 150)
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
StripMetadata: true,
Quality: 85,
ReductionEffort: 2,
})
if err != nil {
t.Fatalf("ExportWebp() error = %v", err)
}
if size != int64(len(want)) {
t.Errorf("WebP output is %d bytes, want %d, the size at effort 2",
size, len(want))
}
}
// TestImageProcessor_AVIFAtEffort1 verifies that AVIF is saved at effort 1
// with 8 bits per sample: the output is the size of the same image saved
// with those settings. The source is 16-bit, which libvips saves with 12
// bits when it is not given a bit depth, and 640x480: on a small image,
// such as 64x48, efforts 1 and 2 give the same output.
func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
t.Parallel()
const width, height = 640, 480
source := image.NewRGBA64(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
source.Set(x, y, color.RGBA64{
R: uint16((x * 65535 / width) & 0xffff),
G: uint16((y * 65535 / height) & 0xffff),
B: 32768,
A: 65535,
})
}
}
input := encodePNG(t, source)
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
StripMetadata: true,
Quality: 85,
Effort: 1,
Bitdepth: 8,
})
if err != nil {
t.Fatalf("ExportAvif() error = %v", err)
}
if size != int64(len(want)) {
t.Errorf("AVIF output is %d bytes, want %d, the size at effort 1 "+
"and 8 bits", size, len(want))
}
}
// TestImageProcessor_JPEGXLAtEffort4 verifies that JPEG XL is saved at
// effort 4, not libvips' default of 7: the output is the size of the same
// image saved at effort 4, with the distance exportJXL gives quality 85.
func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 200, 150)
size := processedSize(t, input, &Request{Format: FormatJXL, Quality: 85})
params := vips.NewJxlExportParams()
params.Quality = 85
params.Distance = jxlDistance(85)
params.Effort = 4
want, _, err := decode(t, input).ExportJxl(params)
if err != nil {
t.Fatalf("ExportJxl() error = %v", err)
}
if size != int64(len(want)) {
t.Errorf("JPEG XL output is %d bytes, want %d, the size at effort 4",
size, len(want))
}
}
@@ -1,62 +0,0 @@
package imageprocessor
import (
"bytes"
"errors"
"testing"
)
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
// which scales the input to cover the requested size and so past it on one
// side, is refused with ErrOutputTooLarge when that side would come out above
// MaxOutputDimension, and otherwise makes the scaled size. A 1024x256 or
// 256x1024 input scaled by 8 has 8192 for its long side.
func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputWidth, inputHeight int
size Size
// wantWidth and wantHeight are the output's, 0 for a refusal
wantWidth, wantHeight int
}{
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0},
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0},
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048},
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192},
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
if result != nil {
t.Logf("Process() made %dx%d", result.Width, result.Height)
}
if tt.wantWidth == 0 {
if !errors.Is(err, ErrOutputTooLarge) {
t.Errorf("Process() error = %v, want %v", err, ErrOutputTooLarge)
}
return
}
if err != nil {
t.Fatalf("Process() error = %v", err)
}
if result.Width != tt.wantWidth || result.Height != tt.wantHeight {
t.Errorf("Process() made %dx%d, want %dx%d",
result.Width, result.Height, tt.wantWidth, tt.wantHeight)
}
})
}
}
+130 -412
View File
@@ -7,11 +7,11 @@ import (
"errors"
"fmt"
"io"
"runtime"
"sync"
"time"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/config"
)
// vipsOnce ensures vips is initialized exactly once.
@@ -20,10 +20,11 @@ import (
var vipsOnce sync.Once
// initVips initializes libvips with quiet logging, one worker thread per
// image and no operation cache. 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.
// 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() {
vipsOnce.Do(func() {
vips.LoggingSettings(nil, vips.LogLevelError)
@@ -36,24 +37,6 @@ func initVips() {
})
}
// errNoJPEGXL is returned by CheckJPEGXLSupport.
var errNoJPEGXL = errors.New("libvips lacks JPEG XL support: install " +
"vips-jxl on Alpine, or use a libvips built with libjxl")
// CheckJPEGXLSupport returns an error, naming the fix, when libvips
// cannot load and save JPEG XL.
func CheckJPEGXLSupport() error {
initVips()
// govips counts a format as supported when libvips has its loader;
// libvips builds the JPEG XL loader and saver together.
if !vips.IsTypeSupported(vips.ImageTypeJXL) {
return errNoJPEGXL
}
return nil
}
// Format represents supported output image formats.
type Format string
@@ -64,7 +47,6 @@ const (
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif"
)
@@ -132,39 +114,10 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
// configured byte limit.
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// MaxOutputDimension is the maximum width or height of an output image, and
// so of a requested size (imgcache.MaxDimension).
const MaxOutputDimension = 8192
// ErrOutputTooLarge is returned, before resizing, when the output would have a
// side above MaxOutputDimension: the side of a size with one side 0, computed
// from the input's proportions, or with FitOutside, the side scaled past the
// requested size.
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
// MaxAVIFOutputPixels is the most pixels an AVIF output may have, so that an
// encode takes at most 40 seconds of the default downstream_timeout of 60
// seconds; AVIF is already at the lowest effort govips can set (avifEffort).
// With one thread, as pixad runs libvips, saving 4096x4096, 8192x2048 or
// 2048x8192 random pixels with an alpha channel takes up to about 34
// seconds, the slowest at any quality and at 8 or 16 bits per sample, and
// 5120x4096 at 8 bits already takes about 36.
const MaxAVIFOutputPixels = 4096 * 4096
// ErrAVIFOutputTooLarge is returned, before anything is decoded, when an AVIF
// output would have more than MaxAVIFOutputPixels pixels.
var ErrAVIFOutputTooLarge = errors.New("AVIF output has too many pixels")
// ErrUnsupportedOutputFormat is returned when the requested output format is
// not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
// libvips reads the input's first bytes as another format than the source
// formats, or as none: for example an SVG whose first bytes pass the
// first-bytes check for AVIF.
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
// processed and none finishes within ProcessingWaitTimeout.
var ErrTooManyImages = errors.New("too many images being processed at once")
@@ -191,7 +144,7 @@ type Params struct {
// If <= 0, DefaultMaxInputBytes is used.
MaxInputBytes int64
// MaxConcurrentProcessing is the most images processed at once.
// If <= 0, config.DefaultMaxConcurrentProcessing() is used.
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
MaxConcurrentProcessing int
}
@@ -207,7 +160,7 @@ func New(params Params) *ImageProcessor {
maxConcurrentProcessing := params.MaxConcurrentProcessing
if maxConcurrentProcessing <= 0 {
maxConcurrentProcessing = config.DefaultMaxConcurrentProcessing()
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
}
return &ImageProcessor{
@@ -220,11 +173,7 @@ func New(params Params) *ImageProcessor {
// 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. When ctx has ended once Process has a slot, or before it
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
// no way to stop libvips once an encode has begun, so an encode keeps its
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
// refused with ErrAVIFOutputTooLarge before anything is decoded.
// ErrTooManyImages.
func (p *ImageProcessor) Process(
ctx context.Context,
input io.Reader,
@@ -237,20 +186,18 @@ func (p *ImageProcessor) Process(
defer release()
data, err := p.readInput(input)
// 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
// the limit without consuming additional memory.
limited := io.LimitReader(input, p.maxInputBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read input: %w", err)
}
// govips picks the libvips loader from the input's first bytes with
// DetermineImageType. Input whose first bytes it reads as no source
// format, such as SVG, which librsvg would render, is refused before
// libvips reads it.
inputFormat := p.detectFormat(vips.DetermineImageType(data))
sourceFormat, err := p.formatFromString(inputFormat)
if err != nil {
return nil, err
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
// Decode image
@@ -271,41 +218,33 @@ func (p *ImageProcessor) Process(
origWidth := img.Width()
origHeight := img.Height()
// Detect input format
inputFormat := p.detectFormat(img)
// Validate input dimensions to prevent DoS via decompression bombs
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
return nil, ErrInputTooLarge
}
// Determine target dimensions; too large ones are refused before resizing
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
if err != nil {
return nil, err
}
// orig is the source's own format; encode refuses an empty format
outputFormat := req.Format
if outputFormat == FormatOriginal {
outputFormat = sourceFormat
}
// Determine output dimensions; too large ones are refused before
// resizing, which with FitCover already decodes the input
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
targetWidth, targetHeight, req.FitMode, outputFormat)
if err != nil {
return nil, err
}
// Determine target dimensions
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
// Resize if needed
if targetWidth != origWidth || targetHeight != origHeight {
err := p.resize(img, outputWidth, outputHeight, req.FitMode)
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
if err != nil {
return nil, fmt.Errorf("failed to resize: %w", err)
}
}
// Determine output format
outputFormat := req.Format
if outputFormat == FormatOriginal || outputFormat == "" {
outputFormat = p.formatFromString(inputFormat)
}
// Encode to target format
output, err := p.encode(ctx, img, outputFormat, req.Quality)
output, err := p.encode(img, outputFormat, req.Quality)
if err != nil {
return nil, fmt.Errorf("failed to encode: %w", err)
}
@@ -324,29 +263,21 @@ func (p *ImageProcessor) Process(
// targetDimensions calculates the output dimensions for a requested size,
// scaling proportionally when only one dimension is given and keeping the
// original dimensions when both are zero. A side scaled proportionally can
// come out above MaxOutputDimension, which is ErrOutputTooLarge.
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
width, height := size.Width, size.Height
// original dimensions when both are zero.
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
switch {
case width == 0 && height == 0:
case size.Width == 0 && size.Height == 0:
// Both are 0: keep original size
return origWidth, origHeight, nil
case width == 0:
return origWidth, origHeight
case size.Width == 0:
// Only height specified: calculate width proportionally
width = origWidth * height / origHeight
case height == 0:
return origWidth * size.Height / origHeight, size.Height
case size.Height == 0:
// Only width specified: calculate height proportionally
height = origHeight * width / origWidth
return size.Width, origHeight * size.Width / origWidth
default:
return size.Width, size.Height
}
if width > MaxOutputDimension || height > MaxOutputDimension {
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
width, height, MaxOutputDimension)
}
return width, height, nil
}
// MIME types for the supported image formats.
@@ -356,7 +287,6 @@ const (
mimeGIF = "image/gif"
mimeWebP = "image/webp"
mimeAVIF = "image/avif"
mimeJXL = "image/jxl"
)
// SupportedInputFormats returns MIME types this processor can read.
@@ -367,7 +297,6 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
mimeGIF,
mimeWebP,
mimeAVIF,
mimeJXL,
}
}
@@ -379,7 +308,6 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
FormatGIF,
FormatWebP,
FormatAVIF,
FormatJXL,
}
}
@@ -396,8 +324,6 @@ func FormatToMIME(format Format) string {
return mimeGIF
case FormatAVIF:
return mimeAVIF
case FormatJXL:
return mimeJXL
case FormatOriginal:
return "application/octet-stream"
default:
@@ -432,50 +358,31 @@ func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
// acquireSlot takes a slot in processingSemaphore, waiting at most
// processingWaitTimeout for one to free up, and returns the func that gives
// it back. When ctx has ended by the time it has a slot, it gives the slot
// back and returns ctx's error.
// 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()
}
release := func() { <-p.processingSemaphore }
// select takes the slot even when ctx has ended at the same moment
err := ctx.Err()
if err != nil {
release()
return nil, err
}
return release, nil
}
// readInput reads input with a size limit to prevent unbounded memory
// consumption, refusing more than maxInputBytes with ErrInputDataTooLarge. It
// reads at most maxInputBytes+1 so it can detect if the input exceeds the
// limit without consuming additional memory.
func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(input, p.maxInputBytes+1))
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
return data, nil
}
// detectFormat returns the format string for a govips image type.
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
switch imageType {
// detectFormat returns the format string from a vips image.
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
format := img.Format()
switch format {
case vips.ImageTypeJPEG:
return "jpeg"
case vips.ImageTypePNG:
@@ -486,72 +393,16 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return string(FormatAVIF)
case vips.ImageTypeJXL:
return string(FormatJXL)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K:
vips.ImageTypeJP2K, vips.ImageTypeJXL:
return "unknown"
default:
return "unknown"
}
}
// outputDimensions returns the dimensions resize gives an image of imgWidth x
// imgHeight for the target dimensions and fit mode, without resizing it, and
// refuses an output too large for format. With FitOutside one side can come
// out larger than the target; when it would be above MaxOutputDimension,
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
func outputDimensions(
imgWidth, imgHeight, width, height int, fit FitMode, format Format,
) (int, int, error) {
scaleW := float64(width) / float64(imgWidth)
scaleH := float64(height) / float64(imgHeight)
outputWidth, outputHeight := width, height
switch fit {
case FitCover, FitFill, "":
// Cover crops, and fill stretches, to exactly the target dimensions
case FitContain:
// Fit within the target dimensions, maintaining aspect ratio
scale := min(scaleW, scaleH)
outputWidth = int(float64(imgWidth) * scale)
outputHeight = int(float64(imgHeight) * scale)
case FitInside:
// Same as contain, but only shrink
scale := min(scaleW, scaleH, 1)
outputWidth = int(float64(imgWidth) * scale)
outputHeight = int(float64(imgHeight) * scale)
case FitOutside:
// The smallest dimension fits, the other may exceed the target
scale := max(scaleW, scaleH)
outputWidth = int(float64(imgWidth) * scale)
outputHeight = int(float64(imgHeight) * scale)
default:
return 0, 0, fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
}
// The side FitOutside scales past the target is held to the maximum too
if max(outputWidth, outputHeight) > MaxOutputDimension {
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
outputWidth, outputHeight, MaxOutputDimension)
}
if format == FormatAVIF && outputWidth*outputHeight > MaxAVIFOutputPixels {
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
}
return outputWidth, outputHeight, nil
}
// resize resizes the image to width x height, the dimensions
// outputDimensions gives for 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 {
@@ -560,20 +411,46 @@ func (p *ImageProcessor) resize(
// Resize and crop to fill exact dimensions (default)
return img.Thumbnail(width, height, vips.InterestingCentre)
case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
scale := min(scaleW, scaleH)
newW := int(float64(imgW) * scale)
newH := int(float64(imgH) * scale)
return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitFill:
// Resize to exact dimensions (may distort)
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case FitInside:
// outputDimensions keeps the dimensions of an image that already fits
if img.Width() == width && img.Height() == height {
return nil
// Same as contain, but only shrink
if img.Width() <= width && img.Height() <= height {
return nil // Already fits
}
return img.Thumbnail(width, height, vips.InterestingNone)
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
scale := min(scaleW, scaleH)
newW := int(float64(imgW) * scale)
newH := int(float64(imgH) * scale)
case FitContain, FitOutside:
return img.Thumbnail(width, height, vips.InterestingNone)
return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitOutside:
// Resize so smallest dimension fits, may exceed target on other dimension
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
scale := max(scaleW, scaleH)
newW := int(float64(imgW) * scale)
newH := int(float64(imgH) * scale)
return img.Thumbnail(newW, newH, vips.InterestingNone)
default:
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
@@ -582,50 +459,44 @@ func (p *ImageProcessor) resize(
const defaultQuality = 85
// encode encodes an image to the specified format. govips gives pixa no way
// to stop libvips once an encode has begun, so when ctx has ended encode
// returns ctx's error without beginning one.
// encode encodes an image to the specified format.
func (p *ImageProcessor) encode(
ctx context.Context, img *vips.ImageRef, format Format, quality int,
img *vips.ImageRef, format Format, quality int,
) ([]byte, error) {
err := ctx.Err()
if err != nil {
return nil, err
}
if quality <= 0 {
quality = defaultQuality
}
// 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)
}
}
var params vips.ExportParams
switch format {
case FormatJPEG:
return exportJPEG(img, quality)
params = vips.ExportParams{
Format: vips.ImageTypeJPEG,
Quality: quality,
}
case FormatPNG:
return exportPNG(img)
params = vips.ExportParams{
Format: vips.ImageTypePNG,
}
case FormatGIF:
return exportGIF(img)
params = vips.ExportParams{
Format: vips.ImageTypeGIF,
}
case FormatWebP:
return exportWebP(img, quality)
params = vips.ExportParams{
Format: vips.ImageTypeWEBP,
Quality: quality,
}
case FormatAVIF:
return exportAVIF(img, quality)
case FormatJXL:
return exportJXL(img, quality)
params = vips.ExportParams{
Format: vips.ImageTypeAVIF,
Quality: quality,
}
case FormatOriginal:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
@@ -633,157 +504,23 @@ func (p *ImageProcessor) encode(
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
}
}
// govips sends libvips Go's zero value for some settings an export leaves
// out, such as no compression at all for PNG, so each export below sets
// every setting whose zero value is not what pixa wants. Stripping metadata
// drops EXIF, XMP, IPTC and the ICC profile.
// exportJPEG encodes img as JPEG at quality, without metadata. The settings
// it leaves out are at libvips' defaults.
func exportJPEG(img *vips.ImageRef, quality int) ([]byte, error) {
output, _, err := img.ExportJpeg(&vips.JpegExportParams{
StripMetadata: true,
Quality: quality,
})
return output, err
}
// pngCompression is libvips' default PNG compression, from 0 (none) to 9.
const pngCompression = 6
// exportPNG encodes img as PNG at libvips' default compression and row
// filter, without metadata.
func exportPNG(img *vips.ImageRef) ([]byte, error) {
output, _, err := img.ExportPng(&vips.PngExportParams{
StripMetadata: true,
Compression: pngCompression,
Filter: vips.PngFilterNone,
})
return output, err
}
// gifEffort is libvips' default GIF effort, from 1 to 10.
const gifEffort = 7
// exportGIF encodes img as GIF at libvips' default effort. govips cannot
// have libvips strip metadata from GIF, which carries none.
func exportGIF(img *vips.ImageRef) ([]byte, error) {
output, _, err := img.ExportGIF(&vips.GifExportParams{Effort: gifEffort})
return output, err
}
// webpEffort is the WebP effort, from 0 (fastest) to 6. It is below libvips'
// default of 4 so that an encode takes at most 40 seconds of the default
// downstream_timeout of 60 seconds. With one thread, as pixad runs libvips,
// saving an 8192x8192 image of random pixels with an alpha channel takes up
// to about 23 seconds at 2, and up to about 16 without one, at any quality;
// with an alpha channel, 3 takes about 50 seconds and 4 about 61.
const webpEffort = 2
// exportWebP encodes img as lossy WebP at quality and webpEffort, without
// metadata.
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
output, _, err := img.ExportWebp(&vips.WebpExportParams{
StripMetadata: true,
Quality: quality,
ReductionEffort: webpEffort,
})
return output, err
}
// avifEffort is the AVIF effort, from 0 (fastest) to 9; 1 is the lowest
// govips can set. With one thread, as pixad runs libvips, 1 takes about 52
// seconds to save an 8192x8192 RGB image of random pixels at quality 85,
// and about 96 with an alpha channel, against the default
// downstream_timeout of 60 seconds, so an AVIF output is held to
// MaxAVIFOutputPixels. On an image of milder noise, which 1 saves in about
// 12 seconds, 2 takes nearly a minute and libvips' default, 4, takes
// minutes.
const avifEffort = 1
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
// libvips would save a 16-bit image with 12, but at avifEffort that takes
// about 54 seconds for a 16-bit 8192x8192 image of milder noise, nearly all
// of the default downstream_timeout, and about 12 seconds with 8.
const avifBitdepth = 8
// exportAVIF encodes img as lossy AVIF at quality, avifEffort and
// avifBitdepth, without metadata.
func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
output, _, err := img.ExportAvif(&vips.AvifExportParams{
StripMetadata: true,
Quality: quality,
Effort: avifEffort,
Bitdepth: avifBitdepth,
})
return output, err
}
// jxlResolution is the resolution every JPEG XL image is saved with, in
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
// JPEG that names none.
const jxlResolution = 72 / 25.4
// jxlEffort is the JPEG XL effort, from 1 (fastest) to 9. It is below
// libvips' default of 7 so that an encode takes at most 40 seconds of the
// default downstream_timeout of 60 seconds. With one thread, as pixad runs
// libvips, saving an 8192x8192 image of random pixels with an alpha channel
// takes up to about 27 seconds at 4, and up to about 13 without one, at any
// quality; with an alpha channel, 5 takes about 43 seconds and 7 about 81.
const jxlEffort = 4
// exportJXL encodes img as JPEG XL at quality and jxlEffort, without
// metadata. govips sends libvips a distance, the JPEG XL encoder's own
// measure of quality, along with the quality, and libvips then uses the
// distance alone, so the quality is also given as a distance.
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
// PNG, but cannot save one as JPEG XL. encode has already converted
// any image with an ICC profile to sRGB, so this is CMYK with none.
if img.Interpretation() == vips.InterpretationCMYK {
err := img.ToColorSpace(vips.InterpretationSRGB)
// 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 CMYK to sRGB: %w", err)
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
}
}
// govips cannot make libvips strip metadata from JPEG XL, so it is
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
// IPTC but keeps the ICC profile.
err := img.RemoveMetadata()
if err != nil {
return nil, err
}
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
// GIF, which carries none of them.
params.StripMetadata = true
err = img.RemoveICCProfile()
if err != nil {
return nil, err
}
// libvips 8.16 and later still write an EXIF block of their own, from
// the image's size, orientation and resolution, and fixed values. The
// image is upright, so its orientation is 1, but its resolution is still
// the source's.
toSave, err := img.CopyChangingResolution(jxlResolution, jxlResolution)
if err != nil {
return nil, err
}
defer toSave.Close()
params := vips.NewJxlExportParams()
params.Quality = quality
params.Distance = jxlDistance(quality)
params.Effort = jxlEffort
output, _, err := toSave.ExportJxl(params)
output, _, err := img.Export(&params)
if err != nil {
return nil, err
}
@@ -791,39 +528,20 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
return output, nil
}
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
// distance, with the formula libvips and libjxl use for their own quality
// setting, except that 100 stays lossy (distance 0.1) where libjxl makes it
// lossless.
//
//nolint:mnd // the constants of that formula
func jxlDistance(quality int) float64 {
q := float64(quality)
if quality >= 30 {
return 0.1 + (100-q)*0.09
}
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
}
// formatFromString converts a format string from detectFormat to Format. A
// format detectFormat could not name is ErrUnsupportedInputFormat.
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
// formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) Format {
switch format {
case "jpeg":
return FormatJPEG, nil
return FormatJPEG
case "png":
return FormatPNG, nil
return FormatPNG
case "gif":
return FormatGIF, nil
return FormatGIF
case "webp":
return FormatWebP, nil
return FormatWebP
case string(FormatAVIF):
return FormatAVIF, nil
case string(FormatJXL):
return FormatJXL, nil
return FormatAVIF
default:
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
return FormatJPEG
}
}
@@ -1,52 +0,0 @@
package imageprocessor
import (
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
// TestCheckJPEGXLSupport fails when libvips lacks JPEG XL support, as on
// Alpine without the vips-jxl package.
func TestCheckJPEGXLSupport(t *testing.T) {
t.Parallel()
err := CheckJPEGXLSupport()
if err != nil {
t.Fatalf("CheckJPEGXLSupport() error = %v", err)
}
}
// TestLibvipsSavesAndLoadsJPEGXL saves an image as JPEG XL with govips and
// loads it back. It fails when libvips lacks JPEG XL support, as on Alpine
// without the vips-jxl package.
func TestLibvipsSavesAndLoadsJPEGXL(t *testing.T) {
t.Parallel()
img, err := vips.NewImageFromBuffer(createTestJPEG(t, 64, 48))
if err != nil {
t.Fatalf("failed to load test JPEG: %v", err)
}
defer img.Close()
jxl, _, err := img.ExportJxl(vips.NewJxlExportParams())
if err != nil {
t.Fatalf("ExportJxl() error = %v", err)
}
loaded, err := vips.NewImageFromBuffer(jxl)
if err != nil {
t.Fatalf("failed to load the JPEG XL image: %v", err)
}
defer loaded.Close()
if loaded.Format() != vips.ImageTypeJXL {
t.Errorf("loaded format = %s, want jxl", vips.ImageTypes[loaded.Format()])
}
if loaded.Width() != 64 || loaded.Height() != 48 {
t.Errorf("loaded size = %dx%d, want 64x48", loaded.Width(), loaded.Height())
}
}
@@ -1,244 +0,0 @@
package imageprocessor
import (
"bytes"
"io"
"math"
"os"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
// checks the content type, and the format and size the output loads as.
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
t.Parallel()
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
if result.ContentType != "image/jxl" {
t.Errorf("ContentType = %q, want image/jxl", result.ContentType)
}
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
output, err := vips.NewImageFromBuffer(data)
if err != nil {
t.Fatalf("failed to load the output: %v", err)
}
defer output.Close()
if output.Format() != vips.ImageTypeJXL {
t.Errorf("output format = %s, want jxl", vips.ImageTypes[output.Format()])
}
if output.Width() != 100 || output.Height() != 75 {
t.Errorf("output size = %dx%d, want 100x75", output.Width(), output.Height())
}
}
// TestImageProcessor_JPEGXLQuality verifies that the quality reaches the JPEG
// XL encoder: the same image comes out smaller at quality 30 than at 90.
func TestImageProcessor_JPEGXLQuality(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 400, 300)
outputBytes := make(map[int]int64)
for _, quality := range []int{30, 90} {
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
if err != nil {
t.Fatalf("Process() at quality %d error = %v", quality, err)
}
_ = result.Content.Close()
outputBytes[quality] = result.ContentLength
}
if outputBytes[30] >= outputBytes[90] {
t.Errorf("%d bytes at quality 30, %d at 90, want fewer at 30",
outputBytes[30], outputBytes[90])
}
}
// TestImageProcessor_JPEGXLDropsSourceEXIF verifies that none of the source's
// EXIF reaches a JPEG XL output. libvips 8.16 and later write an EXIF block of
// their own into JPEG XL (orientation, resolution, size, colour space and
// fixed defaults), and govips cannot ask them to leave it out, so the test
// looks for the source's fields rather than for no EXIF at all.
func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
t.Parallel()
input, err := os.ReadFile("testdata/gps-exif.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
for _, field := range []string{
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
"exif-ifd2-DateTimeOriginal", "exif-ifd3-GPSLatitude",
"exif-ifd3-GPSLongitude",
} {
if value, found := exif[field]; found {
t.Errorf("output has %s: %s", field, value)
}
}
}
// TestImageProcessor_JPEGXLDropsSourceResolution verifies that the source's
// resolution does not reach the EXIF block libvips 8.16 and later write into
// JPEG XL. A JPEG XL image holds its resolution in that block alone, so the
// resolution the output loads with is the block's.
func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
t.Parallel()
// dpi-300.jpg is a flat 8x8 grey image with a resolution of 300 dpi.
input, err := os.ReadFile("testdata/dpi-300.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
// libvips gives the resolution in pixels per millimetre.
xDPI := math.Round(output.ResX() * 25.4)
yDPI := math.Round(output.ResY() * 25.4)
if xDPI == 300 || yDPI == 300 {
t.Errorf("output resolution = %vx%v dpi, the source's", xDPI, yDPI)
}
}
// TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL
// output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does
// for PNG.
func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
t.Parallel()
// orientation-6.jpg is stored 16x8, red on the left and blue on the
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
// Upright it is 8x16, red on top and blue below.
input, err := os.ReadFile("testdata/orientation-6.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
if output.Width() != 8 || output.Height() != 16 {
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
}
top, err := output.GetPoint(4, 0)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
bottom, err := output.GetPoint(4, 15)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
if top[0] <= top[2] || bottom[2] <= bottom[0] {
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
top, bottom)
}
}
// TestImageProcessor_JPEGXLConvertsWideGamutToSRGB verifies that a JPEG XL
// output is converted to sRGB, as TestImageProcessor_ConvertsWideGamutToSRGB
// does for PNG. It does not check for an ICC profile, as libvips reports one
// for every JPEG XL image it loads.
func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
t.Parallel()
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
input, err := os.ReadFile("testdata/display-p3.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
pixel, err := output.GetPoint(4, 4)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
want := []float64{255, 0, 0}
for i := range want {
if math.Abs(pixel[i]-want[i]) > 5 {
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
}
}
}
// TestImageProcessor_JPEGXLFromCMYK verifies that a CMYK JPEG with no ICC
// profile can be served as JPEG XL, in sRGB.
func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
t.Parallel()
// cmyk.jpg is a flat 8x8 CMYK image with no ICC profile, filled with
// full cyan and no magenta, yellow or black.
input, err := os.ReadFile("testdata/cmyk.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
if output.Bands() != 3 {
t.Fatalf("output has %d bands, want 3", output.Bands())
}
pixel, err := output.GetPoint(4, 4)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
// Cyan in sRGB: little red, much green and blue.
if pixel[0] > 50 || pixel[1] < 100 || pixel[2] < 200 {
t.Errorf("pixel = %v, want cyan", pixel)
}
}
// TestJXLDistance verifies the JPEG XL distance for a few qualities: 90 is
// distance 1, the encoder's own default, and 100 stays lossy.
func TestJXLDistance(t *testing.T) {
t.Parallel()
tests := []struct {
quality int
want float64
}{
{quality: 100, want: 0.1},
{quality: 90, want: 1},
{quality: 30, want: 6.4},
{quality: 20, want: 9.0667},
}
for _, tt := range tests {
got := jxlDistance(tt.quality)
if math.Abs(got-tt.want) > 0.0001 {
t.Errorf("jxlDistance(%d) = %v, want %v", tt.quality, got, tt.want)
}
}
}
@@ -115,14 +115,14 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
}
}
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
t.Parallel()
for _, limit := range []int{0, -1} {
proc := New(Params{MaxConcurrentProcessing: limit})
if got := cap(proc.processingSemaphore); got != max(runtime.GOMAXPROCS(0)/2, 1) {
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, half the CPUs",
limit, got, max(runtime.GOMAXPROCS(0)/2, 1))
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))
}
}
@@ -132,30 +132,6 @@ func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
}
}
// TestNewDefaultIsHalfTheCPUsAndAtLeastOne checks that New, given no
// MaxConcurrentProcessing, makes one slot for every two CPUs Go uses,
// rounded down, and one slot when Go uses one CPU.
//
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
func TestNewDefaultIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
cpusBefore := runtime.GOMAXPROCS(0)
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
for _, tc := range []struct{ cpus, want int }{
{1, 1}, {2, 1}, {3, 1}, {8, 4},
} {
runtime.GOMAXPROCS(tc.cpus)
got := cap(New(Params{}).processingSemaphore)
t.Logf("GOMAXPROCS %d: %d slots", tc.cpus, got)
if got != tc.want {
t.Errorf("GOMAXPROCS %d: %d slots, want %d", tc.cpus, got, tc.want)
}
}
}
// 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
@@ -1,39 +0,0 @@
package imageprocessor
import (
"bytes"
"errors"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
// input, which is not a source format, is refused with
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
t.Parallel()
img, err := vips.NewImageFromBuffer(createTestPNG(t, 20, 20))
if err != nil {
t.Fatalf("failed to decode test PNG: %v", err)
}
t.Cleanup(img.Close)
tiff, _, err := img.ExportTiff(vips.NewTiffExportParams())
if err != nil {
t.Fatalf("failed to save test TIFF: %v", err)
}
result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal},
)
if result != nil {
t.Logf("Process() returned %s", result.ContentType)
}
if !errors.Is(err, ErrUnsupportedInputFormat) {
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedInputFormat)
}
}
@@ -1,46 +0,0 @@
package imageprocessor
import (
"bytes"
"io"
"testing"
)
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
// the six source formats, saved by the processor itself, is loaded and saved
// again in its own format (orig).
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
t.Parallel()
proc := New(Params{})
for _, format := range []Format{
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
} {
saved, err := proc.Process(t.Context(),
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
if err != nil {
t.Fatalf("saving the test image as %s: %v", format, err)
}
source, err := io.ReadAll(saved.Content)
if err != nil {
t.Fatalf("reading the %s image: %v", format, err)
}
result, err := proc.Process(t.Context(), bytes.NewReader(source),
&Request{Format: FormatOriginal})
if err != nil {
t.Errorf("orig of the %s image: %v", format, err)
continue
}
t.Logf("orig of the %s image: %s", format, result.ContentType)
if result.ContentType != FormatToMIME(format) {
t.Errorf("orig of the %s image is %s, want %s",
format, result.ContentType, FormatToMIME(format))
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 B

+36 -186
View File
@@ -11,8 +11,6 @@ import (
"io"
"log/slog"
"path/filepath"
"sync"
"sync/atomic"
"time"
lru "github.com/hashicorp/golang-lru/v2"
@@ -32,33 +30,22 @@ const httpStatusOK = 200
// variant not among them is served as before, reading its .meta file.
const metaCacheSize = 10000
// cleanExpiredBatchSize is the most expired negative cache rows one
// statement of CleanExpired deletes. Each batch is a statement of its
// own, so a request waits behind one batch at most, however many rows
// have expired.
const cleanExpiredBatchSize = 1000
// CacheConfig holds cache configuration.
type CacheConfig struct {
StateDir string
CacheTTL time.Duration
NegativeTTL time.Duration
// MaxBytes is the disk cache size limit in bytes that eviction
// enforces. Zero means no limit is enforced (no eviction).
// enforces. Zero means no limit is enforced (no eviction). The
// config layer supplies the computed default when the operator
// omits cache_max_bytes.
MaxBytes int64
// UseDefaultMaxBytes makes NewCache replace MaxBytes with the
// default limit: 75% of the sum of the space free on the filesystem
// holding the cache and the bytes the cache already holds, at least
// DefaultCacheMaxBytesFloor. The config layer sets this when the
// operator omits cache_max_bytes.
UseDefaultMaxBytes bool
// DisableDiskCache turns the disk cache off entirely: no cache
// directories are created, lookups always miss, stores are
// no-ops, and the eviction goroutine only removes the expired
// negative cache rows. The config layer sets this when the
// operator configures cache_max_bytes: 0.
// 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
@@ -87,27 +74,6 @@ type Cache struct {
evictionDone chan struct{}
evictionCancel context.CancelFunc
// The pending counts: hits, misses, upstream fetches and transforms
// whose write to the database missed its request's deadline, kept
// here until they are written by the goroutine that
// StartPendingCountWrites starts. As for eviction, the channels are
// created in NewCache: pendingCountsAdded wakes that goroutine, and
// pendingCountsDone is closed when it returns. pendingCountsCancel,
// set by StartPendingCountWrites, cancels its context, which tells it
// to finish.
pendingHits atomic.Int64
pendingMisses atomic.Int64
pendingUpstreamFetches atomic.Int64
pendingUpstreamFetchBytes atomic.Int64
pendingTransforms atomic.Int64
pendingCountsAdded chan struct{}
pendingCountsDone chan struct{}
pendingCountsCancel context.CancelFunc
// Held through each write of the pending counts, and by
// readHitAndMissCounts to read them
pendingCountsWriteMutex sync.Mutex
// metaCache holds the content types of the variants most recently
// stored or served, so a hit does not read the variant's .meta file.
// It never stands in for the variant file, which is always opened.
@@ -125,29 +91,10 @@ type Cache struct {
// deterministically pause inside that window to exercise
// concurrent stores against it; production code leaves it nil.
evictSourceBlobTestHook func(ContentHash)
// reconciliationPageSize is the most rows one read of a content table
// returns in the reconciliation pass and in Stats. newCache sets it to
// defaultReconciliationPageSize; tests set it smaller.
reconciliationPageSize int
// reconciliationReadTestHook, when set, is called after each of those
// reads with the number of rows the read covered, so tests can check
// that no read covers more than one page; production code leaves it
// nil.
reconciliationReadTestHook func(rows int)
}
// NewCache creates a new cache instance.
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
return newCache(db, config, defaultFreeSpaceProbe)
}
// newCache is NewCache with the free-space probe passed in, so tests
// can fake the free space the default limit is worked out from.
func newCache(
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
) (*Cache, error) {
log := config.Logger
if log == nil {
log = slog.Default()
@@ -167,11 +114,6 @@ func newCache(
evictionDone: make(chan struct{}),
metaCache: metaCache,
contentLocks: newContentLock(),
pendingCountsAdded: make(chan struct{}, 1),
pendingCountsDone: make(chan struct{}),
reconciliationPageSize: defaultReconciliationPageSize,
}
if c.disabled {
@@ -203,20 +145,6 @@ func newCache(
c.variants = variants
c.srcMetadata = srcMetadata
if config.UseDefaultMaxBytes {
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
if err != nil {
return nil, err
}
c.config.MaxBytes = limit
log.Info("computed default cache size limit from free space and cache contents",
"cache_max_bytes", limit,
"cache_dir", filepath.Join(config.StateDir, "cache"),
)
}
return c, nil
}
@@ -489,41 +417,35 @@ func (c *Cache) GetSourceContent(
return c.srcContent.LoadWithSize(contentHash)
}
// CleanExpired removes the negative cache rows past their expiry, one
// batch per statement, until none is left. Once ctx ends it starts no
// further batch and returns ctx's error. The eviction goroutine calls it
// at startup and on every periodic tick, so a row stays about
// NegativeTTL plus one eviction interval, whether or not its URL is
// requested again.
// CleanExpired removes expired entries from the cache.
func (c *Cache) CleanExpired(ctx context.Context) error {
for {
if ctx.Err() != nil {
return ctx.Err()
}
deleted, err := c.cleanExpiredBatch(ctx)
if err != nil {
return err
}
if deleted < cleanExpiredBatchSize {
return nil
}
// Clean expired negative cache entries
_, err := c.db.ExecContext(ctx, `
DELETE FROM negative_cache WHERE expires_at < CURRENT_TIMESTAMP
`)
if err != nil {
return fmt.Errorf("failed to clean negative cache: %w", err)
}
return nil
}
// Stats returns cache statistics.
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
var stats CacheStats
err := c.readHitAndMissCounts(ctx, &stats)
// Fetch hit/miss counts from the stats table
err := c.db.QueryRowContext(ctx, `
SELECT hit_count, miss_count
FROM cache_stats WHERE id = 1
`).Scan(&stats.HitCount, &stats.MissCount)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("failed to get cache stats: %w", err)
}
// Count and size the cached source images and processed variants from
// their tables. A disabled cache holds none, whatever rows an earlier
// run left.
// Count and size the cached source images and processed variants. A
// disabled cache holds none, whatever rows an earlier run left.
if !c.disabled {
err = c.db.QueryRowContext(ctx, `
SELECT (SELECT COUNT(*) FROM source_content)
@@ -533,7 +455,7 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
c.log.Warn("failed to count cache items for stats", "error", err)
}
stats.TotalSizeBytes, err = c.sumContentSizeBytes(ctx)
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
if err != nil {
c.log.Warn("failed to sum cache size for stats", "error", err)
}
@@ -548,30 +470,19 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
}
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
// methods, it writes to the database with ctx's deadline but not its
// cancellation, so an ended request is still counted but does not wait for
// the database past its deadline; a count not written by then becomes a
// pending count (see StartPendingCountWrites).
// fetchBytes bytes, as IncrementUpstreamFetch does.
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
var err error
pendingCount := &c.pendingMisses
if hit {
pendingCount = &c.pendingHits
_, err = c.db.ExecContext(countCtx, `
_, err = c.db.ExecContext(ctx, `
UPDATE cache_stats
SET hit_count = hit_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
} else {
_, err = c.db.ExecContext(countCtx, `
_, err = c.db.ExecContext(ctx, `
UPDATE cache_stats
SET miss_count = miss_count + 1,
last_updated_at = CURRENT_TIMESTAMP
@@ -579,10 +490,7 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
`)
}
switch {
case errors.Is(err, context.DeadlineExceeded):
c.addPendingCount(pendingCount, 1)
case err != nil:
if err != nil {
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
}
@@ -596,22 +504,14 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
return
}
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
_, err := c.db.ExecContext(countCtx, `
_, err := c.db.ExecContext(ctx, `
UPDATE cache_stats
SET upstream_fetch_count = upstream_fetch_count + 1,
upstream_fetch_bytes = upstream_fetch_bytes + ?,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, fetchBytes)
switch {
case errors.Is(err, context.DeadlineExceeded):
c.addPendingCount(&c.pendingUpstreamFetches, 1)
c.addPendingCount(&c.pendingUpstreamFetchBytes, fetchBytes)
case err != nil:
if err != nil {
c.log.Warn("failed to count upstream fetch",
"fetch_bytes", fetchBytes, "error", err)
}
@@ -619,51 +519,25 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
// IncrementTransformCount counts one image transcoded by the image processor.
func (c *Cache) IncrementTransformCount(ctx context.Context) {
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
_, err := c.db.ExecContext(countCtx, `
_, err := c.db.ExecContext(ctx, `
UPDATE cache_stats
SET transform_count = transform_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
switch {
case errors.Is(err, context.DeadlineExceeded):
c.addPendingCount(&c.pendingTransforms, 1)
case err != nil:
if err != nil {
c.log.Warn("failed to count transform", "error", err)
}
}
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
// cache_stats row plus the pending hits and misses (see
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
// write of the pending counts falls between the two reads.
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
c.pendingCountsWriteMutex.Lock()
defer c.pendingCountsWriteMutex.Unlock()
err := c.db.QueryRowContext(ctx, `
SELECT hit_count, miss_count
FROM cache_stats WHERE id = 1
`).Scan(&stats.HitCount, &stats.MissCount)
stats.HitCount += c.pendingHits.Load()
stats.MissCount += c.pendingMisses.Load()
return err
}
// loadVariantWithMeta is GetVariant for a variant metaCache does not
// hold: it reads the content type from the variant's .meta file and
// keeps it in metaCache, unless a StoreVariant has put one there
// meanwhile, as the store's is newer. A read that finds no .meta file,
// as one can when the store's .meta write failed or an eviction removed
// the file after the read opened the variant, serves
// application/octet-stream and keeps nothing, so metaCache only ever
// holds a type read from a .meta file or passed to StoreVariant.
// 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) {
@@ -773,27 +647,3 @@ func (c *Cache) checkNegativeCache(
return true, nil
}
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
// rows past their expiry in one statement, and returns how many it
// deleted. DELETE ... LIMIT needs a SQLite build option, so the subquery
// picks the rows.
func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
result, err := c.db.ExecContext(ctx, `
DELETE FROM negative_cache WHERE id IN (
SELECT id FROM negative_cache
WHERE expires_at < CURRENT_TIMESTAMP
LIMIT ?
)
`, cleanExpiredBatchSize)
if err != nil {
return 0, fmt.Errorf("failed to clean negative cache: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to count cleaned negative cache rows: %w", err)
}
return deleted, nil
}
+2
View File
@@ -102,6 +102,7 @@ func setupTestCache(t *testing.T) *Cache {
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
@@ -240,6 +241,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
// Very short negative TTL for testing
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 1 * time.Millisecond,
})
if err != nil {
@@ -1,265 +0,0 @@
package imgcache
import (
"bytes"
"strconv"
"strings"
"testing"
)
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
// cache content, then drops both content tables. UsageBytes must still
// report what the tables held, and an eviction pass under the limit must
// still succeed: neither may sum the tables.
func TestEvictionReadsTheUsageTotal(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
ctx := t.Context()
kept := storeEvictionTestSource(t, cache, "total.example.com", "/kept.jpg",
bytes.Repeat([]byte{0x71}, 1000))
evicted := storeEvictionTestSource(t, cache, "total.example.com", "/evicted.jpg",
bytes.Repeat([]byte{0x72}, 700))
storeEvictionTestVariant(t, cache, testVariantKeyOne,
bytes.Repeat([]byte{0x73}, 500))
storeEvictionTestVariant(t, cache, testVariantKeyOne,
bytes.Repeat([]byte{0x74}, 300))
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
bytes.Repeat([]byte{0x75}, 200))
// pixa never changes a source's size, but a statement that does must
// change the total too.
_, err := cache.db.ExecContext(ctx,
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
string(kept))
if err != nil {
t.Fatalf("failed to change the source's size: %v", err)
}
err = cache.evictSourceBlob(ctx, evicted)
if err != nil {
t.Fatalf("evictSourceBlob failed: %v", err)
}
err = cache.evictVariant(ctx, testVariantKeyTwo)
if err != nil {
t.Fatalf("evictVariant failed: %v", err)
}
_, err = cache.db.ExecContext(ctx,
`DROP TABLE source_content; DROP TABLE variant_content`)
if err != nil {
t.Fatalf("failed to drop the content tables: %v", err)
}
usage, err := cache.UsageBytes(ctx)
t.Logf("UsageBytes() = %d, error = %v", usage, err)
if err != nil {
t.Fatalf("UsageBytes() error = %v, want nil: it read a content table", err)
}
if usage != 1200 {
t.Errorf("UsageBytes() = %d, want 1200 (900 + 300)", usage)
}
err = cache.EvictToLimit(ctx)
if err != nil {
t.Errorf("EvictToLimit() error = %v, want nil: it read a content table", err)
}
}
// TestReconciliationCorrectsTheUsageTotal sets the total cache usage to a
// wrong value and checks that a reconciliation pass, which sums both
// content tables, puts it right.
func TestReconciliationCorrectsTheUsageTotal(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
ctx := t.Context()
storeEvictionTestSource(t, cache, "total.example.com", "/a.jpg",
bytes.Repeat([]byte{0x76}, 1000))
storeEvictionTestVariant(t, cache, testVariantKeyOne,
bytes.Repeat([]byte{0x77}, 500))
_, err := cache.db.ExecContext(ctx,
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
if err != nil {
t.Fatalf("failed to set a wrong total: %v", err)
}
err = cache.reconcileAccounting(ctx)
if err != nil {
t.Fatalf("reconcileAccounting failed: %v", err)
}
usage, err := cache.UsageBytes(ctx)
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage != 1500 {
t.Errorf("UsageBytes() after reconciliation = %d, want 1500 (1000 + 500)",
usage)
}
}
// TestUsageTotalNotCorrectedFromAnOlderSum stores a variant after the
// change count was read, then offers a sum taken before the store as the
// correction: the total must keep the stored variant, as that sum may
// have missed it.
func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
ctx := t.Context()
var changeCount int64
err := cache.db.QueryRowContext(ctx,
`SELECT change_count FROM cache_usage WHERE id = 1`,
).Scan(&changeCount)
if err != nil {
t.Fatalf("failed to read the change count: %v", err)
}
storeEvictionTestVariant(t, cache, testVariantKeyOne,
bytes.Repeat([]byte{0x78}, 500))
corrected, err := cache.correctUsageTotal(ctx, 0, changeCount)
if err != nil {
t.Fatalf("correctUsageTotal failed: %v", err)
}
if corrected {
t.Error("correctUsageTotal() = true, want false: content changed since the sum")
}
usage, err := cache.UsageBytes(ctx)
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage != 500 {
t.Errorf("UsageBytes() = %d, want 500", usage)
}
}
// TestReconciliationReadsAPageAtATime stores five source images and five
// variants, sets the page size to two rows and runs a reconciliation
// pass. No read the pass makes of a content table, to check its rows or
// to sum them, may cover more than two rows, so a request's query waits
// for one page at most. Between them the reads must still cover every
// row of both tables twice, once to check it and once to sum it, and the
// sum must put a wrong total right.
func TestReconciliationReadsAPageAtATime(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
ctx := t.Context()
const pageSize = 2
cache.reconciliationPageSize = pageSize
// Sources of 100 bytes and variants of 10, five of each.
for i := range 5 {
content := []byte(strconv.Itoa(i))
storeEvictionTestSource(t, cache, "pages.example.com",
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)),
bytes.Repeat(content, 10))
}
_, err := cache.db.ExecContext(ctx,
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
if err != nil {
t.Fatalf("failed to set a wrong total: %v", err)
}
var rowsPerRead []int
cache.reconciliationReadTestHook = func(rows int) {
rowsPerRead = append(rowsPerRead, rows)
}
err = cache.reconcileAccounting(ctx)
if err != nil {
t.Fatalf("reconcileAccounting failed: %v", err)
}
t.Logf("rows covered by each read, in order: %v", rowsPerRead)
coveredRows := 0
for _, rows := range rowsPerRead {
if rows > pageSize {
t.Errorf("a read covered %d rows, want at most %d", rows, pageSize)
}
coveredRows += rows
}
// Ten rows, each read once to check it and once to sum it.
if coveredRows != 20 {
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
}
usage, err := cache.UsageBytes(ctx)
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage != 550 {
t.Errorf("UsageBytes() after reconciliation = %d, want 550 (5*100 + 5*10)",
usage)
}
}
// TestSourceEvictionCandidatesComeFromTheIndex checks that the query
// choosing source images to evict reads source_content in last access
// order from its index, instead of reading and sorting the whole table.
func TestSourceEvictionCandidatesComeFromTheIndex(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
rows, err := cache.db.QueryContext(t.Context(),
`EXPLAIN QUERY PLAN `+sourceCandidatesQuery, evictionBatchSize)
if err != nil {
t.Fatalf("EXPLAIN QUERY PLAN failed: %v", err)
}
defer func() { _ = rows.Close() }()
var plan []string
for rows.Next() {
var id, parent, unused int
var detail string
err := rows.Scan(&id, &parent, &unused, &detail)
if err != nil {
t.Fatalf("failed to scan the query plan: %v", err)
}
plan = append(plan, detail)
}
err = rows.Err()
if err != nil {
t.Fatalf("query plan iteration failed: %v", err)
}
t.Logf("query plan: %q", plan)
if !strings.Contains(strings.Join(plan, "\n"), "idx_source_content_last_accessed") {
t.Errorf("the source candidate query does not use "+
"idx_source_content_last_accessed; plan: %q", plan)
}
}
-85
View File
@@ -1,85 +0,0 @@
package imgcache
import (
"context"
"errors"
"fmt"
"math"
"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
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
// the 75% share used for the computed default limit as integer
// arithmetic (dividing before multiplying avoids overflow).
const (
freeSpaceFractionNumerator uint64 = 3
freeSpaceFractionDenominator uint64 = 4
)
var errNegativeBlockSize = errors.New("statfs reported negative block size")
// 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
}
// computeDefaultMaxBytes returns the default cache size limit: 75% of
// the sum of the free bytes probe reports for <state_dir>/cache/ and
// the bytes the cache already holds, with a floor of
// DefaultCacheMaxBytesFloor. Counting what the cache holds keeps the
// limit from shrinking as the cache fills.
func (c *Cache) computeDefaultMaxBytes(
ctx context.Context, probe FreeSpaceProbeFunc,
) (int64, error) {
cacheDir := filepath.Join(c.config.StateDir, "cache")
freeBytes, err := probe(cacheDir)
if err != nil {
return 0, fmt.Errorf(
"default cache_max_bytes: cannot determine free space for %q: %w",
cacheDir, err)
}
usedBytes, err := c.UsageBytes(ctx)
if err != nil {
return 0, err
}
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
//nolint:gosec // G115: UsageBytes returns the total cache usage, never negative
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
computed = min(computed, math.MaxInt64)
limit := int64(computed)
limit = max(limit, DefaultCacheMaxBytesFloor)
return limit, nil
}
@@ -1,188 +0,0 @@
package imgcache
import (
"errors"
"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")
)
// TestComputeDefaultMaxBytesCountsWhatTheCacheHolds verifies that the
// default limit is 75% of the free space plus what the cache already
// holds, so a cache filled to its limit keeps that limit across a
// restart instead of shrinking to 75% of the space left free.
func TestComputeDefaultMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
// Empty cache, 4 GiB free -> 3 GiB default.
got, err := cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return 4294967296, nil })
if err != nil {
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
}
t.Logf("default for an empty cache with 4 GiB free: %d", got)
if got != 3221225472 {
t.Errorf("default for an empty cache = %d, want 3221225472 (75%% of 4 GiB)",
got)
}
// The cache now holds those 3 GiB, which leaves 1 GiB free.
_, err = cache.db.ExecContext(t.Context(),
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)`,
string(testVariantKeyOne), 3221225472, testContentTypeWebP,
)
if err != nil {
t.Fatalf("failed to insert variant accounting row: %v", err)
}
got, err = cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return 1073741824, nil })
if err != nil {
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
}
t.Logf("default for a cache holding 3 GiB with 1 GiB free: %d", got)
if got != 3221225472 {
t.Errorf("default for a cache holding 3 GiB with 1 GiB free = %d, "+
"want 3221225472 (75%% of 1 GiB + 3 GiB)", got)
}
}
// TestComputeDefaultMaxBytesAppliesFloor verifies that when 75% of the
// free space plus what the cache holds is below 500 MiB, the default
// is floored at DefaultCacheMaxBytesFloor.
func TestComputeDefaultMaxBytesAppliesFloor(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()
cache, _ := newEvictionTestCache(t, 1<<30)
got, err := cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return tc.freeBytes, nil })
if err != nil {
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
}
if got != DefaultCacheMaxBytesFloor {
t.Errorf("computeDefaultMaxBytes = %d, want floor %d",
got, DefaultCacheMaxBytesFloor)
}
})
}
}
// TestComputeDefaultMaxBytesPropagatesProbeError verifies that a
// failing free-space probe produces an error naming cache_max_bytes,
// instead of a silently wrong default.
func TestComputeDefaultMaxBytesPropagatesProbeError(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
_, err := cache.computeDefaultMaxBytes(t.Context(),
func(string) (uint64, error) { return 0, errTestStatfsFailed })
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(), "cache_max_bytes") {
t.Errorf("error %q does not name cache_max_bytes", err.Error())
}
}
// TestNewCacheComputesDefaultMaxBytesWhenAsked verifies that with
// UseDefaultMaxBytes set, the cache's limit becomes the computed
// default, and that the probe is pointed at <state_dir>/cache/, which
// must be created first so statfs measures the right filesystem.
func TestNewCacheComputesDefaultMaxBytesWhenAsked(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
wantCacheDir := filepath.Join(stateDir, "cache")
var probedPath string
// 4 GiB free -> 3 GiB default.
probe := func(path string) (uint64, error) {
probedPath = path
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
path, info, err)
}
return 4294967296, nil
}
cache, err := newCache(evictionTestDB(t), CacheConfig{
StateDir: stateDir,
UseDefaultMaxBytes: true,
}, probe)
if err != nil {
t.Fatalf("newCache returned error: %v", err)
}
if cache.config.MaxBytes != 3221225472 {
t.Errorf("MaxBytes = %d, want computed default 3221225472",
cache.config.MaxBytes)
}
if probedPath != wantCacheDir {
t.Errorf("free space probed at %q, want cache directory %q",
probedPath, wantCacheDir)
}
}
// TestNewCacheKeepsExplicitMaxBytes verifies that without
// UseDefaultMaxBytes the cache keeps MaxBytes exactly as given and
// never consults the free-space probe.
func TestNewCacheKeepsExplicitMaxBytes(t *testing.T) {
t.Parallel()
probe := func(string) (uint64, error) {
t.Error("free-space probe must not be consulted for explicit values")
return 0, errTestProbeNotExpected
}
cache, err := newCache(evictionTestDB(t), CacheConfig{
StateDir: t.TempDir(),
MaxBytes: 1024,
}, probe)
if err != nil {
t.Fatalf("newCache returned error: %v", err)
}
if cache.config.MaxBytes != 1024 {
t.Errorf("MaxBytes = %d, want explicit 1024 (no floor, no recompute)",
cache.config.MaxBytes)
}
}
@@ -15,7 +15,6 @@ import (
"github.com/getsentry/sentry-go"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/magic"
)
@@ -436,47 +435,6 @@ func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
}
}
// TestService_Get_WaiterGetsProcessingTimedOut has a request with no deadline
// join the held fetch of a first request with a short one. With the only
// processing slot held, the first request's transcode waits for it until the
// first request's deadline ends, and stops then. The waiting request, whose
// own context has not ended, must get ErrProcessingTimedOut, which the image
// routes answer 503 as when pixa is busy.
func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchService(t)
svc.processor = imageprocessor.New(
imageprocessor.Params{MaxConcurrentProcessing: 1},
)
holdProcessingSlot(t, svc.processor)
const firstTimeout = 500 * time.Millisecond
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
defer cancelFirst()
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
<-fetcher.started
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
time.Sleep(arrivalWait)
fetcher.releaseFetches()
t.Logf("first request: Get() error = %v", (<-first).err)
got := <-waiter
t.Logf("waiting request: Get() error = %v", got.err)
if !errors.Is(got.err, ErrProcessingTimedOut) {
t.Errorf("waiting request: Get() error = %v, want %v",
got.err, ErrProcessingTimedOut)
}
}
// panickingFetcher panics on every fetch.
type panickingFetcher struct{}
+125 -360
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
@@ -22,12 +21,6 @@ const DefaultEvictionInterval = 5 * time.Minute
// and source blobs) one eviction pass fetches from the database.
const evictionBatchSize = 100
// defaultReconciliationPageSize is the most rows one read of the
// reconciliation pass returns, unless a test sets
// Cache.reconciliationPageSize smaller. Each read is a query of its own,
// so a request waits for one page at most, however large the cache is.
const defaultReconciliationPageSize = 1000
// staleTempFileAge is how old an orphaned temp file (left behind by a
// crashed write) must be before reconciliation removes it. Fresh temp
// files may still belong to an in-flight store.
@@ -52,9 +45,7 @@ const fallbackContentType = "application/octet-stream"
// UsageBytes returns the total number of bytes of cache content
// tracked in the database (source content blobs plus processed
// variants). It reads the total the database keeps up to date as rows
// are added and removed, so it neither scans the cache directories nor
// sums the tables.
// variants). It never scans the cache directories.
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
if c.disabled {
return 0, nil
@@ -62,11 +53,12 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
var total int64
err := c.db.QueryRowContext(ctx,
`SELECT total_size_bytes FROM cache_usage WHERE id = 1`,
).Scan(&total)
err := c.db.QueryRowContext(ctx, `
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
`).Scan(&total)
if err != nil {
return 0, fmt.Errorf("failed to read cache usage: %w", err)
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
}
return total, nil
@@ -244,19 +236,16 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
return candidates, nil
}
// sourceCandidatesQuery selects the least recently used source blobs. It
// orders by the last_accessed_at column itself, not by an expression, so
// SQLite reads the rows in order from that column's index instead of
// sorting the whole table.
const sourceCandidatesQuery = `
SELECT content_hash, size_bytes, last_accessed_at
FROM source_content
ORDER BY last_accessed_at ASC, content_hash ASC
LIMIT ?`
// sourceCandidates returns the least recently used source blobs.
// sourceCandidates returns the least recently used source blobs. Rows
// written before the LRU column existed fall back to fetched_at.
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
rows, err := c.db.QueryContext(ctx, sourceCandidatesQuery, evictionBatchSize)
rows, err := c.db.QueryContext(ctx, `
SELECT content_hash, size_bytes,
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
FROM source_content
ORDER BY lru ASC, content_hash ASC
LIMIT ?
`, evictionBatchSize)
if err != nil {
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
}
@@ -438,16 +427,14 @@ func (c *Cache) notifyWritePressure() {
}
// StartEviction launches the background eviction goroutine, which
// reconciles the database accounting with the cache directories and
// removes the expired negative cache rows 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. On a
// disabled cache it only removes the expired negative cache rows, as
// the negative cache is used either way. It is a no-op when already
// started. The goroutine outlives the caller, so it runs with its own
// context, which StopEviction cancels.
// 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. The goroutine outlives the caller, so it runs
// with its own context, which StopEviction cancels.
func (c *Cache) StartEviction(interval time.Duration) {
if c.evictionCancel != nil {
if c.disabled || c.evictionCancel != nil {
return
}
@@ -483,7 +470,6 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
defer close(c.evictionDone)
c.runReconciliationPass(ctx)
c.runCleanExpired(ctx)
c.runEvictionPass(ctx)
ticker := time.NewTicker(interval)
@@ -504,7 +490,6 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
// unaccounted for to one interval, on a process that is
// already running this loop regardless.
c.runReconciliationPass(ctx)
c.runCleanExpired(ctx)
case <-c.evictionPressure:
}
@@ -540,20 +525,6 @@ func (c *Cache) runReconciliationPass(ctx context.Context) {
}
}
// runCleanExpired runs CleanExpired, logging a failure instead of
// propagating it (the loop must keep running). It does nothing once ctx
// is cancelled.
func (c *Cache) runCleanExpired(ctx context.Context) {
if ctx.Err() != nil {
return
}
err := c.CleanExpired(ctx)
if err != nil {
c.log.Warn("negative cache cleanup 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
@@ -561,14 +532,11 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
// insert under transient DB contention), drops accounting rows whose
// files are missing, removes source blob files the database does not
// know (and rows whose files are gone), removes source and variant files
// that are empty or not the size their rows record, sweeps stale temp
// files left behind by crashed writes, and last checks the total cache
// usage against the tables. It reads the tables a page at a time.
// Running it periodically, not just once, bounds how long such drift can
// accumulate unaccounted for on a long-running process to one eviction
// interval. Once ctx is cancelled, it stops at the next file or row and
// returns ctx's error.
// know (and rows whose files are gone), and sweeps stale temp files
// left behind by crashed writes. Running it periodically, not just
// once, bounds how long such drift can accumulate unaccounted for on a
// long-running process to one eviction interval. Once ctx is cancelled,
// it stops at the next file or row and returns ctx's error.
func (c *Cache) reconcileAccounting(ctx context.Context) error {
if c.disabled {
return nil
@@ -594,12 +562,11 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
return err
}
return c.reconcileUsageTotal(ctx)
return nil
}
// reconcileVariantFiles walks the variant storage directory, checking
// each variant file against its accounting row (reconcileVariantFile)
// and sweeping stale temp files.
// 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,
@@ -624,55 +591,38 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
return nil
}
return c.reconcileVariantFile(ctx, path, entry, VariantKey(name))
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
},
)
}
// reconcileVariantFile checks one variant file against its accounting
// row. A file without a row is adopted into the accounting, unless it is
// empty. A file that is empty, or not the size its row records, as a
// crash or a restored snapshot can leave it, is removed with its row and
// a warning naming it, so the variant is converted again.
func (c *Cache) reconcileVariantFile(
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
) error {
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat variant file: %w", err)
}
var recordedSize int64
err = c.db.QueryRowContext(ctx,
`SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey),
).Scan(&recordedSize)
switch {
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
return c.adoptVariantFile(ctx, path, info, cacheKey)
case err != nil && !errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("failed to check variant accounting row: %w", err)
case info.Size() > 0 && info.Size() == recordedSize:
return nil
}
// The file is empty, or not the size its row records
c.log.Warn("removing damaged cached variant file", "path", path,
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
return c.evictVariant(ctx, cacheKey)
}
// 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, info fs.FileInfo, cacheKey VariantKey,
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, `
_, err = c.db.ExecContext(ctx, `
INSERT INTO variant_content
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
VALUES (?, ?, ?, ?, ?)
@@ -707,63 +657,46 @@ func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
// reconcileVariantRows drops accounting rows whose variant files are
// missing, so the database never references deleted content.
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
var after VariantKey
for {
keys, err := c.variantKeysAfter(ctx, after)
if err != nil {
return err
}
if c.reconciliationReadTestHook != nil {
c.reconciliationReadTestHook(len(keys))
}
if len(keys) == 0 {
return nil
}
for _, key := range keys {
if ctx.Err() != nil {
return ctx.Err()
}
if c.variants.Exists(key) {
continue
}
_, err := c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
if err != nil {
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
}
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
}
after = keys[len(keys)-1]
keys, err := c.allVariantKeys(ctx)
if err != nil {
return err
}
for _, key := range keys {
if ctx.Err() != nil {
return ctx.Err()
}
if c.variants.Exists(key) {
continue
}
_, err := c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
if err != nil {
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
}
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
}
return nil
}
// variantKeysAfter returns, in order, up to c.reconciliationPageSize
// tracked variant cache keys that sort after the given one.
func (c *Cache) variantKeysAfter(
ctx context.Context, after VariantKey,
) ([]VariantKey, error) {
return queryStringColumn[VariantKey](ctx, c.db, `
SELECT cache_key FROM variant_content
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
`, "variant keys", "variant key", string(after), c.reconciliationPageSize)
// allVariantKeys returns every tracked variant cache key.
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
return queryStringColumn[VariantKey](ctx, c.db,
`SELECT cache_key FROM variant_content`, "variant keys", "variant key")
}
// queryStringColumn runs a single-column query with args and returns the
// column values as T. plural names the set for the query and scan
// failure messages; singular names one row for the scan and iteration
// failure messages.
// queryStringColumn runs a single-column query and returns the column
// values as T. plural names the set for the query and scan failure
// messages; singular names one row for the scan and iteration failure
// messages.
func queryStringColumn[T ~string](
ctx context.Context, db *sql.DB, query, plural, singular string, args ...any,
ctx context.Context, db *sql.DB, query, plural, singular string,
) ([]T, error) {
rows, err := db.QueryContext(ctx, query, args...)
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
}
@@ -791,9 +724,10 @@ func queryStringColumn[T ~string](
return values, nil
}
// reconcileSourceFiles walks the source content directory, checking
// each blob file against its source_content row (reconcileSourceFile)
// and sweeping stale temp files.
// 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,
@@ -814,55 +748,31 @@ func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
return nil
}
return c.reconcileSourceFile(ctx, path, entry, ContentHash(name))
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
},
)
}
// reconcileSourceFile checks one source blob file against its
// source_content row. A file without a row is removed: it is
// unreachable, as source lookups always go through source_metadata. A
// file that is empty, or not the size its row records, as a crash or a
// restored snapshot can leave it, is removed with its rows and a warning
// naming it, so the source is fetched again.
func (c *Cache) reconcileSourceFile(
ctx context.Context, path string, entry fs.DirEntry, contentHash ContentHash,
) error {
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat source content file: %w", err)
}
var recordedSize int64
err = c.db.QueryRowContext(ctx,
`SELECT size_bytes FROM source_content WHERE content_hash = ?`,
string(contentHash),
).Scan(&recordedSize)
switch {
case errors.Is(err, sql.ErrNoRows):
return c.removeUntrackedSourceFile(ctx, path, contentHash)
case err != nil:
return fmt.Errorf("failed to check source content row: %w", err)
case info.Size() > 0 && info.Size() == recordedSize:
return nil
}
// The file is empty, or not the size its row records
c.log.Warn("removing damaged cached source file", "path", path,
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
return c.evictSourceBlob(ctx, contentHash)
}
// 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 {
_, err := c.db.ExecContext(ctx,
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)
@@ -881,183 +791,38 @@ func (c *Cache) removeUntrackedSourceFile(
// reconcileSourceRows removes source_content rows (and their metadata
// references and sidecars) whose blob files are missing on disk.
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
var after ContentHash
hashes, err := c.allSourceContentHashes(ctx)
if err != nil {
return err
}
for {
hashes, err := c.sourceContentHashesAfter(ctx, after)
for _, hash := range hashes {
if ctx.Err() != nil {
return ctx.Err()
}
if c.srcContent.Exists(hash) {
continue
}
// The blob file is already gone; evictSourceBlob removes the
// rows and sidecars and tolerates the missing file.
err := c.evictSourceBlob(ctx, hash)
if err != nil {
return err
}
if c.reconciliationReadTestHook != nil {
c.reconciliationReadTestHook(len(hashes))
}
if len(hashes) == 0 {
return nil
}
for _, hash := range hashes {
if ctx.Err() != nil {
return ctx.Err()
}
if c.srcContent.Exists(hash) {
continue
}
// The blob file is already gone; evictSourceBlob removes the
// rows and sidecars and tolerates the missing file.
err := c.evictSourceBlob(ctx, hash)
if err != nil {
return err
}
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
}
after = hashes[len(hashes)-1]
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
}
}
// sourceContentHashesAfter returns, in order, up to
// c.reconciliationPageSize tracked source content hashes that sort after
// the given one.
func (c *Cache) sourceContentHashesAfter(
ctx context.Context, after ContentHash,
) ([]ContentHash, error) {
return queryStringColumn[ContentHash](ctx, c.db, `
SELECT content_hash FROM source_content
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
`, "source content hashes", "content hash", string(after),
c.reconciliationPageSize)
}
// Each of these queries sums size_bytes over the next page of rows of
// one content table, the rows that sort after a key, and returns the
// page's last key, the sum and the number of rows in the page. Past the
// last row the page has no rows and the key is NULL.
const (
sourceSizePageQuery = `
SELECT MAX(content_hash), COALESCE(SUM(size_bytes), 0), COUNT(*)
FROM (
SELECT content_hash, size_bytes FROM source_content
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
)`
variantSizePageQuery = `
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
FROM (
SELECT cache_key, size_bytes FROM variant_content
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
)`
)
// sumContentSizeBytes sums size_bytes over both content tables, a page
// of rows per query.
func (c *Cache) sumContentSizeBytes(ctx context.Context) (int64, error) {
sourceBytes, err := c.sumSizeBytesInPages(ctx, sourceSizePageQuery)
if err != nil {
return 0, err
}
variantBytes, err := c.sumSizeBytesInPages(ctx, variantSizePageQuery)
if err != nil {
return 0, err
}
return sourceBytes + variantBytes, nil
}
// sumSizeBytesInPages runs pageQuery, one of the size page queries
// above, from the first page to the last and adds up the page sums.
func (c *Cache) sumSizeBytesInPages(
ctx context.Context, pageQuery string,
) (int64, error) {
var total int64
after := ""
for {
var lastKey sql.NullString
var pageBytes int64
var pageRows int
err := c.db.QueryRowContext(ctx, pageQuery, after, c.reconciliationPageSize).
Scan(&lastKey, &pageBytes, &pageRows)
if err != nil {
return 0, fmt.Errorf("failed to sum cache content sizes: %w", err)
}
if c.reconciliationReadTestHook != nil {
c.reconciliationReadTestHook(pageRows)
}
if pageRows == 0 {
return total, nil
}
total += pageBytes
after = lastKey.String
}
}
// reconcileUsageTotal checks the total cache usage the database keeps
// against size_bytes summed over both content tables, and corrects the
// total when they differ.
func (c *Cache) reconcileUsageTotal(ctx context.Context) error {
var totalBytes, changeCount int64
err := c.db.QueryRowContext(ctx,
`SELECT total_size_bytes, change_count FROM cache_usage WHERE id = 1`,
).Scan(&totalBytes, &changeCount)
if err != nil {
return fmt.Errorf("failed to read cache usage: %w", err)
}
sumBytes, err := c.sumContentSizeBytes(ctx)
if err != nil {
return err
}
if sumBytes == totalBytes {
return nil
}
corrected, err := c.correctUsageTotal(ctx, sumBytes, changeCount)
if err != nil || !corrected {
return err
}
c.log.Warn("corrected total cache usage to the sum of the content tables",
"previous_usage_bytes", totalBytes, "usage_bytes", sumBytes)
return nil
}
// correctUsageTotal sets the total cache usage to sumBytes, a sum of the
// content tables taken when the change count was changeCount, and
// reports whether it did. If a row was added, removed or resized since,
// the count has moved and the total is left alone: the sum may have
// missed that change, and the next pass checks again.
func (c *Cache) correctUsageTotal(
ctx context.Context, sumBytes, changeCount int64,
) (bool, error) {
result, err := c.db.ExecContext(ctx, `
UPDATE cache_usage SET total_size_bytes = ?
WHERE id = 1 AND change_count = ?
`, sumBytes, changeCount)
if err != nil {
return false, fmt.Errorf("failed to correct cache usage: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("failed to read cache usage correction: %w", err)
}
return affected > 0, nil
// allSourceContentHashes returns every tracked source content hash.
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
return queryStringColumn[ContentHash](ctx, c.db,
`SELECT content_hash FROM source_content`,
"source content hashes", "content hash")
}
// sweepStaleTempFile removes a temp file left behind by a crashed
@@ -70,6 +70,7 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
// backwards compatibility with existing fixtures.
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
MaxBytes: maxBytes,
DisableDiskCache: maxBytes == 0,
-6
View File
@@ -21,13 +21,7 @@ const (
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif"
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
// request from its Accept header once the URL's signature or token has
// been checked; it is never processed or cached as itself.
FormatAuto ImageFormat = "auto"
)
// Size represents requested image dimensions
+1
View File
@@ -4,5 +4,6 @@ import "time"
// CacheConfig defaults.
const (
DefaultCacheTTL = 24 * time.Hour
DefaultNegativeTTL = 5 * time.Minute
)
@@ -3,7 +3,6 @@ package imgcache
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
@@ -16,6 +15,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: dir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
@@ -63,6 +63,7 @@ func TestNegativeCache_Expired(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: dir,
CacheTTL: time.Hour,
NegativeTTL: 1 * time.Millisecond, // very short TTL
})
if err != nil {
@@ -95,307 +96,6 @@ func TestNegativeCache_Expired(t *testing.T) {
}
}
// TestService_Get_ServesCachedVariantOfNegativeCachedURL caches a variant of
// the test photo, then keeps a failed fetch of the photo's URL in the negative
// cache, as a failed fetch for another variant does once the source has been
// evicted. The cached variant is still served, and a variant not in the cache
// is made from the cached source. Once the source is evicted, a variant not in
// the cache needs a fetch and is refused.
func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
_ = resp.Content.Close()
err = svc.cache.StoreNegative(t.Context(), photoVariant(fixtures, 85, FitCover),
httpStatusBadGateway, "upstream answered 503")
if err != nil {
t.Fatalf("StoreNegative() error = %v", err)
}
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
if err != nil {
t.Fatalf("Get() of the cached variant error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Errorf("cached variant CacheStatus = %q, want %q",
resp.CacheStatus, CacheHit)
}
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
t.Logf("Get() of a variant made from the cached source: error = %v", err)
if err != nil {
t.Fatalf("Get() of a variant made from the cached source error = %v", err)
}
_ = resp.Content.Close()
contentHash, _, err := svc.cache.LookupSource(t.Context(),
photoVariant(fixtures, 85, FitCover))
if err != nil || contentHash == "" {
t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err)
}
err = svc.cache.evictSourceBlob(t.Context(), contentHash)
if err != nil {
t.Fatalf("evictSourceBlob() error = %v", err)
}
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, FitCover))
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
if !errors.Is(err, ErrNegativeCached) {
t.Errorf("Get() of a variant whose source was evicted error = %v, want %v",
err, ErrNegativeCached)
}
}
// TestEvictionLoopRemovesExpiredNegativeCacheRows stores a failed fetch
// that expires a second later and starts the eviction loop with a short
// interval: a later tick must remove its row although its URL is never
// requested again, and must keep a row that has not expired. It runs
// with the disk cache enabled and disabled, as the negative cache is
// used either way.
func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
t.Parallel()
tests := []struct {
name string
disableDiskCache bool
}{
{name: "disk cache enabled", disableDiskCache: false},
{name: "disk cache disabled", disableDiskCache: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: time.Second,
DisableDiskCache: tt.disableDiskCache,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
expired := &ImageRequest{
SourceHost: testHostExample, SourcePath: "/gone.jpg",
}
err = cache.StoreNegative(t.Context(), expired, 404, "not found")
if err != nil {
t.Fatalf("StoreNegative failed: %v", err)
}
const notExpiredPath = "/later.jpg"
_, err = cache.db.ExecContext(t.Context(), `
INSERT INTO negative_cache
(source_host, source_path, status_code, expires_at)
VALUES (?, ?, 404, datetime('now', '+1 hour'))
`, testHostExample, notExpiredPath)
if err != nil {
t.Fatalf("failed to insert a row that has not expired: %v", err)
}
started := time.Now()
cache.StartEviction(100 * time.Millisecond)
defer func() { _ = cache.StopEviction(t.Context()) }()
for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 {
if time.Since(started) > 10*time.Second {
t.Fatal("the expired row is still in negative_cache")
}
time.Sleep(50 * time.Millisecond)
}
t.Logf("the expired row was removed %s after the loop started",
time.Since(started))
if negativeCacheRowCount(t, cache, notExpiredPath) != 1 {
t.Error("the row that has not expired was removed")
}
})
}
}
// negativeCacheRowCount returns how many negative_cache rows hold
// sourcePath.
func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int {
tb.Helper()
var count int
err := cache.db.QueryRowContext(tb.Context(),
`SELECT COUNT(*) FROM negative_cache WHERE source_path = ?`,
sourcePath,
).Scan(&count)
if err != nil {
tb.Fatalf("failed to count negative_cache rows: %v", err)
}
return count
}
// TestCleanExpiredDeletesInBatches stores one expired negative cache row
// more than one batch holds, and one row that has not expired. One
// statement must delete one batch and no more, so a request waits behind
// one batch at most; CleanExpired must then remove the expired row left
// over and keep the row that has not expired.
func TestCleanExpiredDeletesInBatches(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
ctx := t.Context()
insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1)
const notExpiredPath = "/later.jpg"
_, err = cache.db.ExecContext(ctx, `
INSERT INTO negative_cache
(source_host, source_path, status_code, expires_at)
VALUES (?, ?, 404, datetime('now', '+1 hour'))
`, testHostExample, notExpiredPath)
if err != nil {
t.Fatalf("failed to insert a row that has not expired: %v", err)
}
deleted, err := cache.cleanExpiredBatch(ctx)
if err != nil {
t.Fatalf("cleanExpiredBatch failed: %v", err)
}
t.Logf("one statement deleted %d of %d expired rows",
deleted, cleanExpiredBatchSize+1)
if deleted != cleanExpiredBatchSize {
t.Errorf("one statement deleted %d rows, want %d",
deleted, cleanExpiredBatchSize)
}
err = cache.CleanExpired(ctx)
if err != nil {
t.Fatalf("CleanExpired failed: %v", err)
}
if count := negativeCacheTableRowCount(t, cache); count != 1 {
t.Errorf("negative_cache holds %d rows after CleanExpired, want 1", count)
}
if negativeCacheRowCount(t, cache, notExpiredPath) != 1 {
t.Error("the row that has not expired was removed")
}
}
// TestCleanExpiredRemovesMoreThanOneBatch stores one expired negative
// cache row more than one batch holds and calls CleanExpired once: it
// must remove them all, so it fails if CleanExpired stops after its
// first batch.
func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1)
before := negativeCacheTableRowCount(t, cache)
err = cache.CleanExpired(t.Context())
if err != nil {
t.Fatalf("CleanExpired failed: %v", err)
}
after := negativeCacheTableRowCount(t, cache)
t.Logf("CleanExpired removed %d of %d expired rows, %d per batch",
before-after, before, cleanExpiredBatchSize)
if after != 0 {
t.Errorf("negative_cache holds %d rows after CleanExpired, want 0", after)
}
}
// TestCleanExpiredStopsOnceContextEnds runs CleanExpired with a context
// that has already ended: it must return the context's error and delete
// no row.
func TestCleanExpiredStopsOnceContextEnds(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
insertExpiredNegativeCacheRows(t, cache, 1)
ctx, cancel := context.WithCancel(t.Context())
cancel()
err = cache.CleanExpired(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("CleanExpired() error = %v, want context.Canceled", err)
}
if count := negativeCacheTableRowCount(t, cache); count != 1 {
t.Errorf("negative_cache holds %d rows, want the expired row kept", count)
}
}
// insertExpiredNegativeCacheRows inserts count negative_cache rows that
// expired an hour ago, each for a different source path.
func insertExpiredNegativeCacheRows(t *testing.T, cache *Cache, count int) {
t.Helper()
for i := range count {
_, err := cache.db.ExecContext(t.Context(), `
INSERT INTO negative_cache
(source_host, source_path, status_code, expires_at)
VALUES (?, ?, 404, datetime('now', '-1 hour'))
`, testHostExample, "/expired/"+strconv.Itoa(i)+".jpg")
if err != nil {
t.Fatalf("failed to insert an expired row: %v", err)
}
}
}
// negativeCacheTableRowCount returns how many rows negative_cache holds.
func negativeCacheTableRowCount(tb testing.TB, cache *Cache) int {
tb.Helper()
var count int
err := cache.db.QueryRowContext(tb.Context(),
`SELECT COUNT(*) FROM negative_cache`,
).Scan(&count)
if err != nil {
tb.Fatalf("failed to count negative_cache rows: %v", err)
}
return count
}
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
t.Parallel()
@@ -1,359 +0,0 @@
package imgcache
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
// getImage calls Get for req and returns the image it answered with and
// its cache status.
func getImage(t *testing.T, svc *Service, req *ImageRequest) ([]byte, CacheStatus) {
t.Helper()
resp, err := svc.Get(t.Context(), req)
if err != nil {
t.Fatalf("Get() of quality %d error = %v", req.Quality, err)
}
defer func() { _ = resp.Content.Close() }()
body, err := io.ReadAll(resp.Content)
if err != nil {
t.Fatalf("reading the image of quality %d: %v", req.Quality, err)
}
return body, resp.CacheStatus
}
// fileSize returns the size of the file at path.
func fileSize(t *testing.T, path string) int64 {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
return info.Size()
}
// truncateFile cuts the file at path down to size bytes.
func truncateFile(t *testing.T, path string, size int64) {
t.Helper()
t.Logf("cutting %s from %d to %d bytes",
filepath.Base(path), fileSize(t, path), size)
err := os.Truncate(path, size)
if err != nil {
t.Fatalf("truncating %s: %v", path, err)
}
}
// cachedSourcePath returns the path of the cached source file req uses.
func cachedSourcePath(t *testing.T, svc *Service, req *ImageRequest) string {
t.Helper()
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
if err != nil || contentHash == "" {
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
}
return svc.cache.srcContent.hashToPath(contentHash)
}
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
// when it starts, and returns what the pass logged.
func reconcileAsAtStartup(t *testing.T, cache *Cache) string {
t.Helper()
var logBuf bytes.Buffer
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
err := cache.reconcileAccounting(t.Context())
if err != nil {
t.Fatalf("reconcileAccounting() error = %v", err)
}
t.Logf("reconciliation log: %s", logBuf.String())
return logBuf.String()
}
// assertWarningNames checks that logs hold a warning naming the file at
// path.
func assertWarningNames(t *testing.T, logs, path string) {
t.Helper()
for line := range strings.Lines(logs) {
if strings.Contains(line, `"level":"WARN"`) &&
strings.Contains(line, `"path":"`+path+`"`) {
return
}
}
t.Errorf("reconciliation logged no warning naming %s", path)
}
// TestService_Get_FetchesAgainForEmptyCachedSource empties the cached source
// file, as a crash or a restored snapshot could, and runs the reconciliation
// pass, which must remove it with a warning naming it. It then asks for two
// new sizes of the source: the first must fetch it again and cache it whole,
// so that the second is made from the cache without a third fetch.
func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
wholeSize := fileSize(t, path)
truncateFile(t, path, 0)
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
t.Errorf("upstream fetches = %d, want 2: the empty cached source "+
"was kept and fetched again on every new size", fetches)
}
size := fileSize(t, path)
if size != wholeSize {
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
}
}
// TestService_Get_FetchesAgainForPartialCachedSource cuts the cached source
// file to half its size, as a crash or a restored snapshot could, and runs
// the reconciliation pass, which must remove it with a warning naming it.
// It then asks for two new sizes of the source: the first must fetch it
// again rather than fail to decode the partial file, and the second is
// made from the cache.
func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
wholeSize := fileSize(t, path)
truncateFile(t, path, wholeSize/2)
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
t.Errorf("upstream fetches = %d, want 2", fetches)
}
size := fileSize(t, path)
if size != wholeSize {
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
}
}
// assertConvertedAgain caches a variant of the photo, cuts its file to size
// bytes and runs the reconciliation pass, which must remove it with a
// warning naming it. It then checks that Get converts the image again
// instead of serving the file, and that the next Get serves the whole
// variant from the cache.
func assertConvertedAgain(t *testing.T, size int64) {
t.Helper()
svc, fixtures := SetupTestService(t)
req := photoVariant(fixtures, 85, FitCover)
want, _ := getImage(t, svc, req)
path := svc.cache.variants.keyToPath(CacheKey(req))
truncateFile(t, path, size)
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
got, status := getImage(t, svc, req)
t.Logf("after the cut: %d bytes, %s", len(got), status)
if status != CacheMiss || !bytes.Equal(got, want) {
t.Errorf("after the cut: Get() = %d bytes, %s; want the %d bytes "+
"converted again, %s", len(got), status, len(want), CacheMiss)
}
got, status = getImage(t, svc, req)
t.Logf("next request: %d bytes, %s", len(got), status)
if status != CacheHit || !bytes.Equal(got, want) {
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
len(got), status, len(want), CacheHit)
}
}
// TestService_Get_ConvertsAgainForEmptyCachedVariant empties a cached
// variant file, as a crash or a restored snapshot could, and checks that it
// is converted again rather than served empty.
func TestService_Get_ConvertsAgainForEmptyCachedVariant(t *testing.T) {
t.Parallel()
assertConvertedAgain(t, 0)
}
// TestService_Get_ConvertsAgainForPartialCachedVariant cuts a cached
// variant file to ten bytes, as a crash or a restored snapshot could, and
// checks that it is converted again rather than served cut short.
func TestService_Get_ConvertsAgainForPartialCachedVariant(t *testing.T) {
t.Parallel()
assertConvertedAgain(t, 10)
}
// TestReconciliationRemovesEmptyVariantFileWithoutRow checks that the
// reconciliation pass removes an empty variant file that has no
// accounting row, with a warning naming it, instead of adding a row for
// it that would have it served empty.
func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil),
testContentTypeWebP)
if err != nil {
t.Fatalf("failed to store an empty variant file: %v", err)
}
logs := reconcileAsAtStartup(t, cache)
assertWarningNames(t, logs, cache.variants.keyToPath(testVariantKeyOne))
if cache.variants.Exists(testVariantKeyOne) {
t.Error("the empty variant file is still on disk")
}
rows := countRows(t, cache,
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`,
string(testVariantKeyOne))
if rows != 0 {
t.Errorf("the empty variant file got %d accounting rows, want 0", rows)
}
}
// TestVariantStorage_StoreReplacesMetaFile stores a variant over one
// already stored and checks that Store puts a new .meta file in place of
// the old one, as it does the image, rather than writing into the old
// file, which a crash could leave empty. The old .meta file is given a
// second name before the store, so that it can be told apart from the new
// one after it.
func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
t.Parallel()
storage, key := storeTestVariant(t)
metaPath := storage.keyToPath(key) + variantMetaSuffix
oldMetaPath := metaPath + ".old"
err := os.Link(metaPath, oldMetaPath)
if err != nil {
t.Fatalf("giving the .meta file a second name: %v", err)
}
_, err = storage.Store(key, bytes.NewReader([]byte("new variant data")),
testContentTypeJPEG)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
oldMeta, err := os.Stat(oldMetaPath)
if err != nil {
t.Fatalf("stat of the old .meta file: %v", err)
}
newMeta, err := os.Stat(metaPath)
if err != nil {
t.Fatalf("stat of the new .meta file: %v", err)
}
if os.SameFile(oldMeta, newMeta) {
t.Error("Store() wrote into the old .meta file instead of putting " +
"a new one in its place")
}
reader, _, contentType, err := storage.LoadWithMeta(key)
if err != nil {
t.Fatalf("LoadWithMeta() error = %v", err)
}
_ = reader.Close()
t.Logf("content type after the store: %s", contentType)
if contentType != testContentTypeJPEG {
t.Errorf("content type = %q, want %q", contentType, testContentTypeJPEG)
}
}
// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a
// store over a file of another size at the content's path, such as a
// partial file a crash left, replaces it with the content.
func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
t.Parallel()
storage, err := NewContentStorage(t.TempDir())
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
content := []byte("the whole source image")
sum := sha256.Sum256(content)
hash := ContentHash(hex.EncodeToString(sum[:]))
path := storage.hashToPath(hash)
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
if err != nil {
t.Fatalf("creating the content's directory: %v", err)
}
err = os.WriteFile(path, content[:5], StorageFilePerm)
if err != nil {
t.Fatalf("writing a partial file: %v", err)
}
_, err = storage.StoreHashed(hash, content)
if err != nil {
t.Fatalf("StoreHashed() error = %v", err)
}
stored, err := storage.Load(hash)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
defer func() { _ = stored.Close() }()
got, err := io.ReadAll(stored)
if err != nil {
t.Fatalf("reading the stored content: %v", err)
}
if !bytes.Equal(got, content) {
t.Errorf("stored file = %q, want %q", got, content)
}
}
-149
View File
@@ -1,149 +0,0 @@
package imgcache
import (
"context"
"fmt"
"sync/atomic"
)
// StartPendingCountWrites starts the goroutine that writes the pending
// counts to the database whenever there are some, one UPDATE at a time:
// however many requests pass their deadline before their counts are
// written, at most this one write waits for the database. It is a no-op
// when already started. The goroutine outlives the caller, so it runs with
// its own context, which StopPendingCountWrites cancels to tell it to
// finish.
func (c *Cache) StartPendingCountWrites() {
if c.pendingCountsCancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
c.pendingCountsCancel = cancel
go func() {
c.pendingCountWriteLoop(ctx)
}()
}
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
// started, if it did, to finish, and waits for its write under way to end
// and for it to return. It then writes the pending counts left, so that
// they are written before the database closes. It waits for all of this at
// most until ctx ends, then logs the counts not written and returns an
// error.
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
var err error
if c.pendingCountsCancel != nil {
c.pendingCountsCancel()
select {
case <-c.pendingCountsDone:
case <-ctx.Done():
// The write given up on here may not reach the database:
// database/sql closes without waiting for a query, and a
// write still waiting for the connection fails when the
// database closes.
err = fmt.Errorf("pending counts still being written: %w", ctx.Err())
}
}
if err == nil {
err = c.writePendingCounts(ctx)
}
if err != nil {
c.log.Error("counts not written at shutdown",
"hits", c.pendingHits.Load(),
"misses", c.pendingMisses.Load(),
"upstream_fetches", c.pendingUpstreamFetches.Load(),
"upstream_fetch_bytes", c.pendingUpstreamFetchBytes.Load(),
"transforms", c.pendingTransforms.Load(),
"error", err,
)
return err
}
return nil
}
// pendingCountWriteLoop is the body of the goroutine
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
// and returns, but does not cut off a write under way then. A write that
// fails leaves the counts pending, for the next write.
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
defer close(c.pendingCountsDone)
for {
select {
case <-ctx.Done():
return
case <-c.pendingCountsAdded:
}
// select picks at random when ctx is cancelled and a wakeup
// waits too.
if ctx.Err() != nil {
return
}
err := c.writePendingCounts(context.WithoutCancel(ctx))
if err != nil {
c.log.Warn("failed to write pending counts", "error", err)
}
}
}
// addPendingCount adds n to pendingCount, one of the pending counts, and
// wakes the goroutine that writes them. pendingCountsAdded has capacity one,
// so a wakeup already waiting is enough.
func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
pendingCount.Add(n)
select {
case c.pendingCountsAdded <- struct{}{}:
default:
}
}
// writePendingCounts adds the pending counts to the cache_stats row in one
// UPDATE, then takes what it wrote out of them, leaving any added
// meanwhile. When the UPDATE fails, they all stay pending.
func (c *Cache) writePendingCounts(ctx context.Context) error {
c.pendingCountsWriteMutex.Lock()
defer c.pendingCountsWriteMutex.Unlock()
hits := c.pendingHits.Load()
misses := c.pendingMisses.Load()
upstreamFetches := c.pendingUpstreamFetches.Load()
upstreamFetchBytes := c.pendingUpstreamFetchBytes.Load()
transforms := c.pendingTransforms.Load()
if hits+misses+upstreamFetches+upstreamFetchBytes+transforms == 0 {
return nil
}
_, err := c.db.ExecContext(ctx, `
UPDATE cache_stats
SET hit_count = hit_count + ?,
miss_count = miss_count + ?,
upstream_fetch_count = upstream_fetch_count + ?,
upstream_fetch_bytes = upstream_fetch_bytes + ?,
transform_count = transform_count + ?,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, hits, misses, upstreamFetches, upstreamFetchBytes, transforms)
if err != nil {
return fmt.Errorf("failed to write pending counts: %w", err)
}
c.pendingHits.Add(-hits)
c.pendingMisses.Add(-misses)
c.pendingUpstreamFetches.Add(-upstreamFetches)
c.pendingUpstreamFetchBytes.Add(-upstreamFetchBytes)
c.pendingTransforms.Add(-transforms)
return nil
}
@@ -1,327 +0,0 @@
package imgcache
import (
"context"
"database/sql"
"errors"
"testing"
"time"
)
// pendingCountsWait is how long a test waits for the database's connection,
// for a count to reach the database, for a write to start waiting for the
// connection, for a request to return past its deadline, or for
// StopPendingCountWrites to return.
const pendingCountsWait = 5 * time.Second
// otherWritesWait is how long a test gives other writes, once one write waits
// for the database's connection, to start waiting for it too.
const otherWritesWait = 100 * time.Millisecond
// holdDatabase takes the one connection of cache's database, so that every
// other query waits for it, and returns the func that frees it.
func holdDatabase(t *testing.T, cache *Cache) func() {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), pendingCountsWait)
defer cancel()
conn, err := cache.db.Conn(ctx)
if err != nil {
t.Fatalf("failed to take the database connection: %v", err)
}
return func() { _ = conn.Close() }
}
// waitForConnectionWaits waits until db.Stats().WaitCount, the number of
// times a caller has waited for a connection, reaches waits.
func waitForConnectionWaits(t *testing.T, db *sql.DB, waits int64) {
t.Helper()
deadline := time.Now().Add(pendingCountsWait)
for db.Stats().WaitCount < waits {
if time.Now().After(deadline) {
t.Fatalf("callers waited for the database connection %d times, want %d",
db.Stats().WaitCount, waits)
}
time.Sleep(10 * time.Millisecond)
}
}
// waitForCounters waits until the cache_stats row holds want.
func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) {
t.Helper()
deadline := time.Now().Add(pendingCountsWait)
for {
got := readCacheStatsCounters(t, cache)
if got == want {
return
}
if time.Now().After(deadline) {
t.Fatalf("counters = %+v, want %+v", got, want)
}
time.Sleep(10 * time.Millisecond)
}
}
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
// database's one connection while a request whose fetch is held reaches its
// deadline. The request must return with the deadline's error while the
// connection is still held, as it waits for no database write past its
// deadline, and its miss must reach the database once the connection is free.
func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchService(t)
svc.cache.StartPendingCountWrites()
defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
// Room for the request to reach its fetch on a busy host
const timeout = 2 * time.Second
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
deadline, _ := ctx.Deadline()
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
// The request has made its database reads by the time it fetches.
select {
case <-fetcher.started:
case <-time.After(timeout):
t.Fatal("request did not reach its fetch by its deadline")
}
releaseDatabase := holdDatabase(t, svc.cache)
defer releaseDatabase()
select {
case got := <-results:
t.Logf("Get() returned %v after its deadline, error = %v",
time.Since(deadline), got.err)
if !errors.Is(got.err, context.DeadlineExceeded) {
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
}
case <-time.After(time.Until(deadline) + pendingCountsWait):
t.Fatal("request did not return while the database was busy")
}
releaseDatabase()
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
}
// TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting holds the
// database's one connection while many misses are counted past their
// deadline. At most one write may then wait for the connection, and every
// miss must reach the database once it is free.
func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
cache.StartPendingCountWrites()
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
// Writes given a context past its deadline fail at once.
ended, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
const misses = 20
for range misses {
cache.IncrementStats(ended, false, 0)
}
// Once one write waits for the connection, any others start waiting too.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
time.Sleep(otherWritesWait)
waits := cache.db.Stats().WaitCount - waitsBefore
t.Logf("%d writes waited for the database", waits)
if waits > 1 {
t.Errorf("%d writes waited for the database, want at most 1", waits)
}
releaseDatabase()
waitForCounters(t, cache, cacheStatsCounters{missCount: misses})
}
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss
// their deadline, and checks that Stats adds them to the database's counts
// before they are written.
func TestStats_IncludesPendingCounts(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
_, err := cache.db.ExecContext(t.Context(),
`UPDATE cache_stats SET hit_count = 75, miss_count = 25 WHERE id = 1`)
if err != nil {
t.Fatal(err)
}
// Writes given a context past its deadline fail at once.
ended, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
cache.IncrementStats(ended, true, 0)
cache.IncrementStats(ended, true, 0)
cache.IncrementStats(ended, false, 0)
stats, err := cache.Stats(t.Context())
if err != nil {
t.Fatalf("Stats() error = %v", err)
}
if stats.HitCount != 77 || stats.MissCount != 26 {
t.Errorf("HitCount = %d, MissCount = %d, want 77 and 26",
stats.HitCount, stats.MissCount)
}
want := cacheStatsCounters{hitCount: 75, missCount: 25}
if got := readCacheStatsCounters(t, cache); got != want {
t.Errorf("counters in the database = %+v, want %+v", got, want)
}
}
// TestStopPendingCountWrites_WritesPendingCounts holds the database's one
// connection while the goroutine that writes the pending counts waits for it,
// counts more, then stops that goroutine. The stop must leave the write under
// way to finish rather than cut it off, and once the connection is free it
// must write the counts that write left.
func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
cache.StartPendingCountWrites()
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
// Writes given a context past its deadline fail at once.
ended, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
cache.IncrementStats(ended, true, 0)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
// Counted after that write read the pending counts
cache.IncrementStats(ended, false, 0)
cache.IncrementUpstreamFetch(ended, 1024)
cache.IncrementTransformCount(ended)
// The goroutine is told to finish here as well as by the stop, so that it
// is told before its write under way ends, however late the stop runs.
// The counts above then reach the database only through the stop's own
// write.
cache.pendingCountsCancel()
stopped := make(chan error, 1)
go func() {
stopped <- cache.StopPendingCountWrites(t.Context())
}()
// Had the stop cut off the write under way, its own write would wait for
// the connection too.
time.Sleep(otherWritesWait)
waits := cache.db.Stats().WaitCount - waitsBefore
t.Logf("%d writes waited for the database", waits)
if waits > 1 {
t.Errorf("%d writes waited for the database, want 1: "+
"the stop cut off the write under way", waits)
}
releaseDatabase()
select {
case err := <-stopped:
if err != nil {
t.Fatalf("StopPendingCountWrites() error = %v", err)
}
case <-time.After(pendingCountsWait):
t.Fatal("StopPendingCountWrites() did not return once the database was free")
}
want := cacheStatsCounters{1, 1, 1, 1024, 1}
if got := readCacheStatsCounters(t, cache); got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
}
// TestStopPendingCountWrites_ReturnsWhenItsContextEnds holds the database's
// one connection while the goroutine that writes the pending counts waits for
// it, then stops that goroutine with a context that ends first. The stop must
// return the context's error, and the write under way must still reach the
// database once the connection is free.
func TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
cache.StartPendingCountWrites()
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
// Writes given a context past its deadline fail at once.
ended, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
cache.IncrementStats(ended, true, 0)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
stopCtx, cancelStop := context.WithCancel(t.Context())
cancelStop()
stopped := make(chan error, 1)
go func() {
stopped <- cache.StopPendingCountWrites(stopCtx)
}()
select {
case err := <-stopped:
if !errors.Is(err, context.Canceled) {
t.Errorf("StopPendingCountWrites() error = %v, want %v",
err, context.Canceled)
}
case <-time.After(pendingCountsWait):
t.Fatal("StopPendingCountWrites() did not return once its context ended")
}
releaseDatabase()
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
}
+31 -73
View File
@@ -9,7 +9,6 @@ import (
"log/slog"
"net/url"
"runtime/debug"
"strings"
"time"
"github.com/dustin/go-humanize"
@@ -43,8 +42,7 @@ type Service struct {
type ServiceConfig struct {
// Cache is the cache instance
Cache *Cache
// FetcherConfig configures the upstream fetcher built when Fetcher is
// not set. Its AllowHTTP and MaxResponseSize are used either way.
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
FetcherConfig *httpfetcher.Config
// Fetcher is an optional custom fetcher (for testing)
Fetcher httpfetcher.Fetcher
@@ -53,7 +51,7 @@ type ServiceConfig struct {
// Allowlist is the list of hosts that don't require signatures
Allowlist []string
// MaxConcurrentProcessing is the most images processed at once; zero
// uses the image processor's default, half the CPUs
// uses the image processor's default, one per CPU
MaxConcurrentProcessing int
// Logger for logging
Logger *slog.Logger
@@ -101,13 +99,6 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
allowHTTP = cfg.FetcherConfig.AllowHTTP
}
// JPEG XL is the default output format, so pixad does not start
// without it.
err := imageprocessor.CheckJPEGXLSupport()
if err != nil {
return nil, err
}
maxResponseSize := fetcherCfg.MaxResponseSize
processor := imageprocessor.New(imageprocessor.Params{
MaxInputBytes: maxResponseSize,
@@ -129,20 +120,26 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
// ErrNegativeCached is returned when a URL is in the negative cache (recently failed).
var ErrNegativeCached = errors.New("request is in negative cache (recently failed)")
// ErrProcessingTimedOut is returned when processing an image stopped because
// the downstream_timeout of the request doing it ended, to that request and to
// every request that waited for its image.
var ErrProcessingTimedOut = errors.New(
"image processing stopped at the deadline of the request doing it")
// Get retrieves a processed image, fetching and processing if necessary.
// While its source URL is in the negative cache, a cached variant is still
// served and a variant not in the cache is still made from a cached source;
// only a request that needs the source fetched is refused.
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
// Propagate AllowHTTP setting to the request
req.AllowHTTP = s.allowHTTP
// Check negative cache first - skip fetching for recently-failed URLs
negHit, err := s.cache.checkNegativeCache(ctx, req)
if err != nil {
s.log.Warn("negative cache check failed", "error", err)
}
if negHit {
s.log.Debug("negative cache hit",
"host", req.SourceHost,
"path", req.SourcePath,
)
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
}
// Check variant cache first (disk only, no DB)
result, err := s.cache.Lookup(ctx, req)
if err != nil {
@@ -157,7 +154,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
// Fall through to re-process
} else {
// Counted also when the request context has ended meanwhile
s.cache.IncrementStats(ctx, true, 0)
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
return &ImageResponse{
Content: reader,
@@ -174,7 +171,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
// failed or the request context has ended meanwhile
response, err := s.processOrWait(ctx, req)
s.cache.IncrementStats(ctx, false, 0)
s.cache.IncrementStats(context.WithoutCancel(ctx), false, 0)
if err != nil {
return nil, err
@@ -213,16 +210,7 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
return fmt.Errorf("invalid source URL: %w", err)
}
// An allowlist entry names no port, so a host followed by ":", with or
// without a port, needs a signature. The port is read from the parsed
// URL, as the fetcher reads it: in an IPv6 address written without
// brackets, such as 2001:db8::1:8443, it is what follows the last ":".
// This is checked here and not in allowlist.IsAllowed, which also matches
// referer_blocklist, where a Referer must be blocked whatever its port.
hostHasPort := parsedURL.Port() != "" ||
strings.HasSuffix(parsedURL.Host, ":")
if !hostHasPort && s.allowlist.IsAllowed(parsedURL) {
if s.allowlist.IsAllowed(parsedURL) {
return nil
}
@@ -304,8 +292,14 @@ func (s *Service) processOrWait(
}
}()
processingCtx, cancel := withoutCancelKeepingDeadline(ctx)
defer cancel()
processingCtx := context.WithoutCancel(ctx)
if deadline, ok := ctx.Deadline(); ok {
var cancel context.CancelFunc
processingCtx, cancel = context.WithDeadline(processingCtx, deadline)
defer cancel()
}
return s.processFromSourceOrFetch(processingCtx, req, cacheKey)
})
@@ -342,20 +336,6 @@ func (s *Service) processOrWait(
}, nil
}
// withoutCancelKeepingDeadline returns ctx without its cancellation but with
// its deadline, if it has one, and the func that releases the returned
// context.
func withoutCancelKeepingDeadline(
ctx context.Context,
) (context.Context, context.CancelFunc) {
deadline, hasDeadline := ctx.Deadline()
if !hasDeadline {
return context.WithoutCancel(ctx), func() {}
}
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
}
// loadCachedSource opens source content from cache, without reading it, and
// returns it with its size; nil if the cached data is unavailable, empty or
// exceeds maxResponseSize.
@@ -427,28 +407,12 @@ func (s *Service) processFromSourceOrFetch(
// fetchAndProcess fetches from upstream, processes, and caches the result.
// It counts the fetch with the bytes read from upstream, including when
// reading the response or a later step fails. It fetches nothing while the
// source URL is in the negative cache.
// reading the response or a later step fails.
func (s *Service) fetchAndProcess(
ctx context.Context,
req *ImageRequest,
cacheKey VariantKey,
) (*processedVariant, error) {
// Check negative cache - skip fetching for recently-failed URLs
negHit, err := s.cache.checkNegativeCache(ctx, req)
if err != nil {
s.log.Warn("negative cache check failed", "error", err)
}
if negHit {
s.log.Debug("negative cache hit",
"host", req.SourceHost,
"path", req.SourcePath,
)
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
}
// Fetch from upstream
sourceURL := req.SourceURL()
@@ -481,7 +445,7 @@ func (s *Service) fetchAndProcess(
fetchBytes := int64(len(sourceData))
// Counted also when the request context has ended meanwhile
s.cache.IncrementUpstreamFetch(ctx, fetchBytes)
s.cache.IncrementUpstreamFetch(context.WithoutCancel(ctx), fetchBytes)
if err != nil {
return nil, fmt.Errorf("failed to read upstream response: %w", err)
@@ -555,13 +519,7 @@ func (s *Service) processAndStore(
FitMode: imageprocessor.FitMode(req.FitMode),
}
// ctx has the deadline of the request doing the processing, which can
// end while a request waiting for its image has time left
processResult, err := s.processor.Process(ctx, source, processReq)
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
}
if err != nil {
return nil, fmt.Errorf("image processing failed: %w", err)
}
@@ -569,7 +527,7 @@ func (s *Service) processAndStore(
processDuration := time.Since(processStart)
// Counted also when the request context has ended meanwhile
s.cache.IncrementTransformCount(ctx)
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
// Read processed content
processedData, err := io.ReadAll(processResult.Content)
-259
View File
@@ -2,9 +2,7 @@ package imgcache
import (
"context"
"encoding/json"
"io"
"os"
"testing"
"time"
@@ -250,97 +248,6 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
}
}
// TestService_ValidateRequest_HostWithPortNeedsSignature verifies that a host
// followed by a port, or by a colon with no port, matches no allowlist entry,
// so that a request for it without a signature is refused, while the same
// host with no port is allowlisted.
func TestService_ValidateRequest_HostWithPortNeedsSignature(t *testing.T) {
t.Parallel()
svc, _ := SetupTestService(t,
WithSigningKey(testSigningKey),
WithAllowlist(testHostCDN, "203.0.113.5", "2001:db8::1"),
)
tests := []struct {
host string
allowlisted bool
}{
{testHostCDN, true},
{"cdn.example.com:8443", false},
{"cdn.example.com:443", false},
{"cdn.example.com:", false},
{"203.0.113.5", true},
{"203.0.113.5:8080", false},
{"[2001:db8::1]", true},
{"[2001:db8::1]:8443", false},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
t.Parallel()
req := &ImageRequest{
SourceHost: tt.host,
SourcePath: testPathCat,
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
err := svc.ValidateRequest(req)
t.Logf("ValidateRequest() for host %q with no signature: %v",
tt.host, err)
if allowlisted := err == nil; allowlisted != tt.allowlisted {
t.Errorf("host %q served without a signature = %v, want %v",
tt.host, allowlisted, tt.allowlisted)
}
})
}
}
// TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature verifies that
// an allowlisted IPv6 address written without brackets and followed by a
// port, or by a colon with no port, needs a signature. Go's URL parser, which
// the fetcher uses, reads 2001:db8::1:8443 as the host 2001:db8::1 with the
// port 8443.
func TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature(
t *testing.T,
) {
t.Parallel()
svc, _ := SetupTestService(t,
WithSigningKey(testSigningKey),
WithAllowlist("2001:db8::1"),
)
for _, host := range []string{"2001:db8::1:8443", "2001:db8::1:"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
req := &ImageRequest{
SourceHost: host,
SourcePath: testPathCat,
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
err := svc.ValidateRequest(req)
t.Logf("ValidateRequest() for host %q with no signature: %v",
host, err)
if err == nil {
t.Errorf("host %q served without a signature, want refused",
host)
}
})
}
}
func TestService_Get_InvalidFile(t *testing.T) {
t.Parallel()
@@ -523,172 +430,6 @@ func TestService_Get_Caching(t *testing.T) {
}
}
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
// a variant of it and sets back two days every time pixa stored for them.
// For the source: fetched_at in source_metadata, fetched_at and
// last_accessed_at in source_content, the fetched_at in its metadata file,
// and the access and modification times of its file and of its metadata
// file. For the variant: created_at and last_accessed_at in variant_content,
// the created_at in its .meta file, and the access and modification times of
// its file and of its .meta file. It then checks that the variant is still
// served from the cache and that a new variant is made from the cached source
// without fetching it again.
//
// Left at the present: the files' status change and birth times, which a
// program cannot set, and the times of the directories holding the files,
// which other entries share. The source's stored response headers are empty,
// as the mock fetcher sends none. expires_at, etag and last_modified in
// source_metadata and in the metadata file are never written, nor are the
// request_cache and output_content tables.
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
variant := photoVariant(fixtures, 85, FitCover)
resp, err := svc.Get(t.Context(), variant)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
}
_ = resp.Content.Close()
twoDaysAgo := time.Now().AddDate(0, 0, -2)
backdateSource(t, svc.cache, variant, twoDaysAgo)
backdateVariant(t, svc.cache, variant, twoDaysAgo)
resp, err = svc.Get(t.Context(), variant)
if err != nil {
t.Fatalf("Get() of the cached variant error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Errorf("cached variant CacheStatus = %q, want %q",
resp.CacheStatus, CacheHit)
}
// Another quality is a new variant, made from the cached source
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
if err != nil {
t.Fatalf("Get() of a new variant error = %v", err)
}
_ = resp.Content.Close()
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
if fetches != 1 {
t.Errorf("upstream fetches = %d, want 1", fetches)
}
}
// backdateSource sets every time pixa stored for req's cached source to when,
// as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
func backdateSource(
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
) {
t.Helper()
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
meta, err := cache.srcMetadata.Load(req.SourceHost, pathHash)
if err != nil {
t.Fatalf("reading the source's metadata file: %v", err)
}
meta.FetchedAt = when.Unix()
err = cache.srcMetadata.Store(req.SourceHost, pathHash, meta)
if err != nil {
t.Fatalf("writing the source's metadata file: %v", err)
}
dbTime := when.UTC().Format(sqliteTimestampFormat)
updateOneRow(t, cache, `
UPDATE source_metadata SET fetched_at = ?
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, dbTime, req.SourceHost, req.SourcePath, req.SourceQuery)
updateOneRow(t, cache,
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
dbTime, meta.ContentHash)
setSourceLastAccessed(t, cache, ContentHash(meta.ContentHash), when)
for _, path := range []string{
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
cache.srcMetadata.metaPath(req.SourceHost, pathHash),
} {
err = os.Chtimes(path, when, when)
if err != nil {
t.Fatalf("setting back the times of %s: %v", path, err)
}
}
}
// backdateVariant sets every time pixa stored for the cached variant req asks
// for to when, as listed on
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
func backdateVariant(
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
) {
t.Helper()
key := CacheKey(req)
content, size, contentType, err := cache.variants.LoadWithMeta(key)
if err != nil || contentType == "" {
t.Fatalf("reading the variant's .meta file: content type %q, error %v",
contentType, err)
}
_ = content.Close()
meta, err := json.Marshal(VariantMeta{
ContentType: contentType,
Size: size,
CreatedAt: when.Unix(),
})
if err != nil {
t.Fatalf("encoding the variant's .meta file: %v", err)
}
path := cache.variants.keyToPath(key)
err = os.WriteFile(path+variantMetaSuffix, meta, StorageFilePerm)
if err != nil {
t.Fatalf("writing the variant's .meta file: %v", err)
}
updateOneRow(t, cache,
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
when.UTC().Format(sqliteTimestampFormat), string(key))
setVariantLastAccessed(t, cache, key, when)
for _, file := range []string{path, path + variantMetaSuffix} {
err = os.Chtimes(file, when, when)
if err != nil {
t.Fatalf("setting back the times of %s: %v", file, err)
}
}
}
// updateOneRow runs the UPDATE statement query and fails the test unless it
// changed exactly one row.
func updateOneRow(t *testing.T, cache *Cache, query string, args ...any) {
t.Helper()
result, err := cache.db.ExecContext(t.Context(), query, args...)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
rows, err := result.RowsAffected()
if err != nil || rows != 1 {
t.Fatalf("%s: rows = %d, error = %v, want 1 row", query, rows, err)
}
}
func TestService_Get_DifferentSizes(t *testing.T) {
t.Parallel()
+5
View File
@@ -43,6 +43,7 @@ func TestStats_HitRateIsRatio(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: dir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
@@ -90,6 +91,7 @@ func TestStats_ZeroCounts(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: dir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
@@ -118,6 +120,7 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
})
@@ -159,6 +162,7 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
})
@@ -228,6 +232,7 @@ func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
disabled, err := NewCache(enabled.db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
DisableDiskCache: true,
})
+62 -79
View File
@@ -10,7 +10,6 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
@@ -80,8 +79,8 @@ func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
// from hash, without recomputing it. Callers that already know the
// hash before writing (e.g. because they must hold a hash-keyed lock
// across the whole store operation) use this instead of Store. Like
// Store, it is idempotent: a file of the content's size already at that
// path is left untouched, and a file of another size is replaced.
// Store, it is idempotent: content already on disk at that path is
// left untouched.
func (s *ContentStorage) StoreHashed(
hash ContentHash, data []byte,
) (int64, error) {
@@ -152,35 +151,28 @@ func (s *ContentStorage) Exists(hash ContentHash) bool {
return err == nil
}
// writeIfAbsent writes data with writeFileSynced to the path derived
// from hash, unless a file of the data's size is already there.
// 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)
// A file of another size there is damaged, and is replaced
info, statErr := os.Stat(path)
if statErr == nil && info.Size() == int64(len(data)) {
// Check if already exists
_, statErr := os.Stat(path)
if statErr == nil {
return nil
}
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
// Create directory structure
dir := filepath.Dir(path)
err := os.MkdirAll(dir, StorageDirPerm)
if err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
return writeFileSynced(path, data)
}
// writeFileSynced writes data to path through a temp file in the same
// directory: the temp file is synced to disk, renamed to path, and the
// directory synced after, so that neither a concurrent reader nor a
// crash finds a partial file under path. When the directory sync fails,
// the file is removed from path, so that a failed write leaves no file
// there.
func writeFileSynced(path string, data []byte) error {
dir := filepath.Dir(path)
// 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)
@@ -188,20 +180,18 @@ func writeFileSynced(path string, data []byte) error {
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 temp file: %w", err)
}
err = tmpFile.Sync()
if err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp file: %w", err)
return fmt.Errorf("failed to write content: %w", err)
}
err = tmpFile.Close()
@@ -211,6 +201,7 @@ func writeFileSynced(path string, data []byte) error {
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)
@@ -218,31 +209,6 @@ func writeFileSynced(path string, data []byte) error {
return fmt.Errorf("failed to rename temp file: %w", err)
}
err = syncDirectory(dir)
if err != nil {
_ = os.Remove(path)
return err
}
return nil
}
// syncDirectory syncs the directory dir to disk, so that a file just
// renamed into it is still there after a crash.
func syncDirectory(dir string) error {
d, err := os.Open(filepath.Clean(dir))
if err != nil {
return fmt.Errorf("failed to open directory: %w", err)
}
err = d.Sync()
_ = d.Close()
if err != nil {
return fmt.Errorf("failed to sync directory: %w", err)
}
return nil
}
@@ -405,13 +371,11 @@ func HashPath(path string) PathHash {
// CacheKey generates a unique key for a request variant.
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
// The path and query go through escapePercentAndColon, so that a ":" in
// either cannot be taken for the one between them.
func CacheKey(req *ImageRequest) VariantKey {
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
req.SourceHost,
escapePercentAndColon(req.SourcePath),
escapePercentAndColon(req.SourceQuery),
req.SourcePath,
req.SourceQuery,
req.Size.Width,
req.Size.Height,
req.Format,
@@ -423,14 +387,6 @@ func CacheKey(req *ImageRequest) VariantKey {
return VariantKey(hex.EncodeToString(h[:]))
}
// escapePercentAndColon returns s with each "%" written as "%25" and each
// ":" as "%3A", so that the result holds no ":".
func escapePercentAndColon(s string) string {
s = strings.ReplaceAll(s, "%", "%25")
return strings.ReplaceAll(s, ":", "%3A")
}
// VariantStorage handles key-based file storage for processed image variants.
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
@@ -460,9 +416,7 @@ func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error
return &VariantStorage{baseDir: baseDir, log: log}, nil
}
// Store writes content and metadata to storage at the given key, each
// with writeFileSynced. The .meta file is written first, so that a crash
// cannot leave the content in place without it.
// Store writes content and metadata to storage at the given key.
func (s *VariantStorage) Store(
key VariantKey, r io.Reader, contentType string,
) (int64, error) {
@@ -475,11 +429,46 @@ func (s *VariantStorage) Store(
path := s.keyToPath(key)
metaPath := path + ".meta"
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
// Create directory structure
dir := filepath.Dir(path)
err = os.MkdirAll(dir, StorageDirPerm)
if err != nil {
return 0, fmt.Errorf("failed to create directory: %w", err)
}
// Write content to temp file first, then rename for atomicity
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return 0, fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
_, err = tmpFile.Write(data)
if err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("failed to write content: %w", err)
}
err = tmpFile.Close()
if err != nil {
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename content
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
if err != nil {
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("failed to rename temp file: %w", err)
}
// Write metadata
meta := VariantMeta{
ContentType: contentType,
Size: size,
@@ -491,19 +480,13 @@ func (s *VariantStorage) Store(
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
}
// Metadata write failure is non-fatal: the content is then served as
// application/octet-stream.
err = writeFileSynced(metaPath, metaData)
// Metadata write failure is non-fatal; content is already stored.
err = os.WriteFile(metaPath, metaData, StorageFilePerm)
if err != nil {
s.log.Warn("failed to write variant metadata sidecar",
"path", metaPath, "error", err)
}
err = writeFileSynced(path, data)
if err != nil {
return 0, err
}
return size, nil
}
@@ -407,62 +407,6 @@ func TestCacheKey(t *testing.T) {
}
}
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
// URLs whose path or query holds ":" or "%" get different keys.
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path1, query1 string
path2, query2 string
}{
{
name: "colon moved from path to query",
path1: "/a:b", query1: "",
path2: "/a", query2: "b:",
},
{
name: "colon in path and its percent-encoding",
path1: "/a:b", query1: "",
path2: "/a%3Ab", query2: "",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
key1 := CacheKey(&ImageRequest{
SourceHost: testHostCDN,
SourcePath: tt.path1,
SourceQuery: tt.query1,
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
})
key2 := CacheKey(&ImageRequest{
SourceHost: testHostCDN,
SourcePath: tt.path2,
SourceQuery: tt.query2,
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
})
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
if key1 == key2 {
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
}
})
}
}
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
// that fails is logged at warn and the store still succeeds.
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
@@ -185,6 +185,7 @@ func SetupTestService(
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
+26 -66
View File
@@ -7,15 +7,12 @@ import (
"regexp"
"strconv"
"strings"
"sneak.berlin/go/pixa/internal/imageprocessor"
)
// URL parsing errors.
var (
ErrInvalidPath = errors.New("invalid image path")
ErrMissingHost = errors.New("missing source host")
ErrInvalidHost = errors.New("invalid source host")
ErrMissingSize = errors.New("missing size specification")
ErrInvalidSize = errors.New("invalid size format")
ErrInvalidFormat = errors.New("invalid or unsupported format")
@@ -23,9 +20,8 @@ var (
ErrPathTraversal = errors.New("path traversal detected")
)
// MaxDimension is the maximum allowed width or height, the same as the image
// processor's maximum for an output image.
const MaxDimension = imageprocessor.MaxOutputDimension
// MaxDimension is the maximum allowed width or height.
const MaxDimension = 8192
// ValidateDimension checks one requested width or height; name ("width" or
// "height") appears in the error. 0 means "original size" and is valid.
@@ -42,13 +38,8 @@ func ValidateDimension(name string, value int) error {
return nil
}
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
// and a size with no format, such as "800x600" or "orig"
var sizeFormatRegex = regexp.MustCompile(
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
// percentEscapeRegex matches one percent-escape, such as "%2e"
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
// ParsedURL contains the parsed components of an image proxy URL.
type ParsedURL struct {
@@ -64,13 +55,13 @@ type ParsedURL struct {
Format ImageFormat
}
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
// ParseImagePath parses the path captured by chi's wildcard:
// <host>/<path>/<size>.<format>
// This is the primary entry point when using chi routing.
// Examples:
// - cdn.example.com/photos/cat.jpg/800x600.webp
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
// - cdn.example.com/photos/cat.jpg/orig.png
// - cdn.example.com/photos/cat.jpg/800x600
func ParseImagePath(path string) (*ParsedURL, error) {
// Strip leading slash if present (chi may include it)
path = strings.TrimPrefix(path, "/")
@@ -81,10 +72,8 @@ func ParseImagePath(path string) (*ParsedURL, error) {
return parseImageComponents(path)
}
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
// for JPEG XL. A source query follows the source path after a "?", which the
// URL writes as %3F.
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>
// Use ParseImagePath instead when working with chi's wildcard capture.
func ParseImageURL(urlPath string) (*ParsedURL, error) {
// Remove the /v1/image/ prefix
const prefix = "/v1/image/"
@@ -100,10 +89,15 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
return parseImageComponents(remainder)
}
// parseImageComponents parses <host>/<path>/<size>.<format>, or
// <host>/<path>/<size> for JPEG XL.
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
func parseImageComponents(remainder string) (*ParsedURL, error) {
// Find the last path segment, which holds "size" or "size.format"
// Check for path traversal before any other processing
err := checkPathTraversal(remainder)
if err != nil {
return nil, err
}
// Find the last path segment which contains size.format
lastSlash := strings.LastIndex(remainder, "/")
if lastSlash == -1 {
return nil, ErrMissingSize
@@ -141,30 +135,12 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
return nil, ErrMissingHost
}
// A URL reads what comes before "@" as a user name and password, and the
// rest as the host
if strings.Contains(host, "@") {
return nil, fmt.Errorf("%w: %q holds @", ErrInvalidHost, host)
}
// Host names do not depend on letter case, so the allowlist, the upstream
// connection limits, the cache and the signature see one spelling of each
// host
host = strings.ToLower(host)
// Extract query string if present in path
if qIndex := strings.Index(path, "?"); qIndex != -1 {
query = path[qIndex+1:]
path = path[:qIndex]
}
// The source query names no file on the upstream, so only the host and
// the source path are checked for path traversal
err = checkPathTraversal(host + path)
if err != nil {
return nil, err
}
// Ensure path starts with /
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -190,7 +166,12 @@ func checkPathTraversal(path string) error {
// Decode multiple times to catch double-encoding
decoded := path
for range 3 {
newDecoded := decodePercentEscapes(decoded)
newDecoded, err := url.PathUnescape(decoded)
if err != nil {
// Malformed encoding is suspicious
return ErrPathTraversal
}
if newDecoded == decoded {
break
}
@@ -231,19 +212,7 @@ func checkPathTraversal(path string) error {
return nil
}
// decodePercentEscapes decodes every percent-escape in s, such as %2e, and
// keeps a "%" that does not start one as it is, where url.PathUnescape would
// refuse the whole string. A source path holding %25, a literal "%", has such
// a "%" after one more decoding.
func decodePercentEscapes(s string) string {
return percentEscapeRegex.ReplaceAllStringFunc(s, func(escape string) string {
decoded, _ := url.PathUnescape(escape)
return decoded
})
}
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
// parseSizeFormat parses strings like "800x600.webp" or "orig.png"
func parseSizeFormat(s string) (Size, ImageFormat, error) {
matches := sizeFormatRegex.FindStringSubmatch(s)
if matches == nil {
@@ -256,11 +225,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
)
if matches[4] == "orig" {
// "orig" or "orig.format" pattern
// "orig.format" pattern
size = Size{Width: 0, Height: 0}
formatStr = matches[5]
} else {
// "WxH" or "WxH.format" pattern
// "WxH.format" pattern
width, err := strconv.Atoi(matches[1])
if err != nil {
return Size{}, "", ErrInvalidSize
@@ -285,11 +254,6 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
return Size{}, "", err
}
// A URL that names no format is served, and signed, as JPEG XL
if formatStr == "" {
return size, FormatJXL, nil
}
format, err := parseFormat(formatStr)
if err != nil {
return Size{}, "", err
@@ -311,12 +275,8 @@ func parseFormat(s string) (ImageFormat, error) {
return FormatWebP, nil
case "avif":
return FormatAVIF, nil
case "jxl":
return FormatJXL, nil
case "gif":
return FormatGIF, nil
case "auto":
return FormatAuto, nil
default:
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
}
@@ -111,21 +111,6 @@ func TestParseImageURL(t *testing.T) {
}
}
// TestParseImageURL_AutoFormat verifies that the format auto in an image URL
// parses as FormatAuto.
func TestParseImageURL_AutoFormat(t *testing.T) {
t.Parallel()
got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto")
if err != nil {
t.Fatalf("ParseImageURL() error = %v", err)
}
if got.Format != FormatAuto {
t.Errorf("Format = %q, want %q", got.Format, FormatAuto)
}
}
func TestParseImageURL_Errors(t *testing.T) {
t.Parallel()
@@ -234,49 +219,6 @@ func TestParseImagePath(t *testing.T) {
}
}
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
// that every spelling of its letter case is the same host to the allowlist,
// the upstream connection limits and the cache, and that the path keeps its
// case.
func TestParseImagePath_LowerCasesHost(t *testing.T) {
t.Parallel()
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
if err != nil {
t.Fatalf("ParseImagePath() error = %v", err)
}
t.Logf("parsed host %q, path %q", got.Host, got.Path)
if got.Host != testHostCDN {
t.Errorf("Host = %q, want %q", got.Host, testHostCDN)
}
if got.Path != "/Photos/Cat.jpg" {
t.Errorf("Path = %q, want %q", got.Path, "/Photos/Cat.jpg")
}
}
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
// refused: a URL reads what comes before the "@" as a user name and password
// and the rest as the host.
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
t.Parallel()
for _, input := range []string{
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
} {
got, err := ParseImagePath(input)
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
if !errors.Is(err, ErrInvalidHost) {
t.Errorf("ParseImagePath(%q) error = %v, want %v",
input, err, ErrInvalidHost)
}
}
}
func TestParsedURL_ToImageRequest(t *testing.T) {
t.Parallel()
@@ -407,71 +349,6 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
}
}
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
// a source query holding %25, the percent-escape of a literal "%", are
// accepted as they are, although decoding them once more leaves a "%" that
// does not start a percent-escape.
func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want *ParsedURL
}{
{
name: "path",
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
want: &ParsedURL{
Host: testHostCDN, Path: "/sale/50%25off.jpg",
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
},
},
{
name: "query",
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
want: &ParsedURL{
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
},
},
}
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)
})
}
}
// TestParseImageURL_DoubleEncodedTraversalNextToPercent verifies that a
// double-encoded ".." is still refused when the path also holds a "%" that
// does not start a percent-escape, either as written or after one more
// decoding.
func TestParseImageURL_DoubleEncodedTraversalNextToPercent(t *testing.T) {
t.Parallel()
for _, input := range []string{
"/v1/image/cdn.example.com/photos/%252e%252e/secret/50%25off.jpg/800x600.jpeg",
"/v1/image/cdn.example.com/photos/%252e%252e/secret/100%/800x600.jpeg",
} {
_, err := ParseImageURL(input)
t.Logf("ParseImageURL(%q) error = %v", input, err)
if !errors.Is(err, ErrPathTraversal) {
t.Errorf("ParseImageURL(%q) error = %v, want ErrPathTraversal",
input, err)
}
}
}
// errorIs checks if err matches target (handles wrapped errors).
func errorIs(err, target error) bool {
if errors.Is(err, target) {
-87
View File
@@ -1,87 +0,0 @@
// Package loadtestorigin is the upstream host script/loadtest points pixad
// at, run by cmd/loadtest-origin. It answers every request, whatever its path,
// with the same generated JPEG, so each new path is a new source image for
// pixad to fetch, and it logs one line per request, so its log counts pixad's
// fetches.
package loadtestorigin
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"log/slog"
"math"
"net/http"
"os"
"time"
)
const (
listenAddress = ":80"
readHeaderTimeout = 10 * time.Second
imageWidth = 1600
imageHeight = 1200
jpegQuality = 85
)
// Run makes the image and serves it on port 80 until the server fails, then
// exits the process with status 1.
func Run() {
photo, err := makeJPEG()
if err != nil {
slog.Error("cannot make the image", "error", err)
os.Exit(1)
}
server := &http.Server{
Addr: listenAddress,
Handler: newHandler(photo),
ReadHeaderTimeout: readHeaderTimeout,
}
err = server.ListenAndServe()
slog.Error("server stopped", "error", err)
os.Exit(1)
}
// newHandler answers every request with photo and logs the request's path.
func newHandler(photo []byte) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Info("request", "path", r.URL.Path)
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(photo)
})
}
// makeJPEG draws colour gradients crossed with a fine pattern, so the image
// has detail to decode and does not compress to almost nothing.
func makeJPEG() ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
// red and green count up from 0 to 255 and wrap around, along each row
// and down the image.
var green uint8
for y := range imageHeight {
var red uint8
for x := range imageWidth {
img.SetRGBA(x, y, color.RGBA{
R: red, G: green, B: red ^ green, A: math.MaxUint8,
})
red++
}
green++
}
var buf bytes.Buffer
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -1,51 +0,0 @@
package loadtestorigin
import (
"bytes"
"image/jpeg"
"net/http"
"net/http/httptest"
"testing"
)
// TestEveryPathServesTheSameJPEG checks that the origin answers any path with
// 200 and the same JPEG, so every new path script/loadtest asks pixad for is
// a valid source image.
func TestEveryPathServesTheSameJPEG(t *testing.T) {
t.Parallel()
photo, err := makeJPEG()
if err != nil {
t.Fatalf("makeJPEG: %v", err)
}
size, err := jpeg.DecodeConfig(bytes.NewReader(photo))
if err != nil {
t.Fatalf("the image does not decode as a JPEG: %v", err)
}
if size.Width != imageWidth || size.Height != imageHeight {
t.Errorf("the image is %dx%d, want %dx%d",
size.Width, size.Height, imageWidth, imageHeight)
}
handler := newHandler(photo)
for _, path := range []string{"/", "/miss/1.jpg", "/herd/2.jpg"} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("%s: status = %d, want %d", path, rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("%s: Content-Type = %q, want image/jpeg", path, ct)
}
if !bytes.Equal(rec.Body.Bytes(), photo) {
t.Errorf("%s: the body is not the image", path)
}
}
}
-58
View File
@@ -1,58 +0,0 @@
package magic
import "testing"
// testMIMEJXL is the MIME type of JPEG XL.
const testMIMEJXL = "image/jxl"
// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a
// bare codestream and that of the container, are detected as image/jxl.
func TestDetectFormatJPEGXL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data []byte
}{
{"codestream", pad(0xFF, 0x0A)},
{"container", pad(
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := DetectFormat(tt.data)
if err != nil || got != MIMETypeJXL {
t.Errorf("DetectFormat() = %q, %v, want %q", got, err, testMIMEJXL)
}
err = ValidateMagicBytes(tt.data, testMIMEJXL)
if err != nil {
t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err)
}
})
}
}
// TestJPEGXLMIMEType verifies that image/jxl is a supported input type and
// maps to and from the format jxl.
func TestJPEGXLMIMEType(t *testing.T) {
t.Parallel()
if !IsSupportedMIMEType(testMIMEJXL) {
t.Errorf("IsSupportedMIMEType(%q) = false", testMIMEJXL)
}
format, ok := MIMEToImageFormat(testMIMEJXL)
if format != FormatJXL || !ok {
t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true",
testMIMEJXL, format, ok, FormatJXL)
}
if got := ImageFormatToMIME(FormatJXL); got != testMIMEJXL {
t.Errorf("ImageFormatToMIME(%q) = %q, want %q", FormatJXL, got, testMIMEJXL)
}
}
+1 -19
View File
@@ -26,7 +26,6 @@ const (
MIMETypeWebP = MIMEType("image/webp")
MIMETypeGIF = MIMEType("image/gif")
MIMETypeAVIF = MIMEType("image/avif")
MIMETypeJXL = MIMEType("image/jxl")
MIMETypeSVG = MIMEType("image/svg+xml")
)
@@ -41,7 +40,6 @@ const (
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif"
)
@@ -64,11 +62,6 @@ var (
// AVIF uses the ftyp box with brand "avif" or "avis"
// Format: size(4 bytes) + "ftyp" + brand(4 bytes)
magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp"
// JPEG XL is a bare codestream or a codestream in a container
magicJXLCodestream = []byte{0xFF, 0x0A}
magicJXLContainer = []byte{
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
}
)
// WebP identifier appears at offset 8 after RIFF header.
@@ -122,12 +115,6 @@ func DetectFormat(data []byte) (MIMEType, error) {
}
}
// Check JPEG XL (FF0A, or the 12-byte container signature)
if bytes.HasPrefix(data, magicJXLCodestream) ||
bytes.HasPrefix(data, magicJXLContainer) {
return MIMETypeJXL, nil
}
// Check SVG - look for XML declaration or SVG tag
if detectSVG(data) {
return MIMETypeSVG, nil
@@ -194,8 +181,7 @@ func normalizeMIMEType(mimeType string) string {
func IsSupportedMIMEType(mimeType string) bool {
normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) {
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF,
MIMETypeJXL, MIMETypeSVG:
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF, MIMETypeSVG:
return true
default:
return false
@@ -240,8 +226,6 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
return FormatGIF, true
case MIMETypeAVIF:
return FormatAVIF, true
case MIMETypeJXL:
return FormatJXL, true
case MIMETypeSVG:
// SVG has no corresponding output format.
return "", false
@@ -263,8 +247,6 @@ func ImageFormatToMIME(format ImageFormat) string {
return string(MIMETypeGIF)
case FormatAVIF:
return string(MIMETypeAVIF)
case FormatJXL:
return string(MIMETypeJXL)
case FormatOriginal:
// Original format passes content through unchanged.
return mimeOctetStream
+12 -95
View File
@@ -8,15 +8,12 @@ import (
"net/http"
"net/netip"
"regexp"
"strings"
"time"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/prometheus/client_golang/prometheus"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
@@ -38,10 +35,13 @@ 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.
// 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'; " +
"style-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
@@ -66,10 +66,6 @@ type Middleware struct {
log *slog.Logger
config *config.Config
clientIP *clientip.Resolver
// metricsRegistry is the registry Metrics registers its metrics with.
// nil is Prometheus' default registry, the one /metrics serves.
metricsRegistry prometheus.Registerer
}
// New creates a new Middleware instance.
@@ -105,10 +101,9 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
// IPv4 address it carries, since every such address falls in the same /64.
// Counts are kept only for the current and the previous window, so memory
// stays bounded. limitHandler writes the 429 after Retry-After is set; nil
// keeps the rate limit library's plain-text "Too Many Requests".
// stays bounded.
func (s *Middleware) RateLimit(
requestLimit int, window time.Duration, limitHandler http.HandlerFunc,
requestLimit int, window time.Duration,
) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, window,
func(r *http.Request) (string, error) {
@@ -120,8 +115,7 @@ func (s *Middleware) RateLimit(
}
return httprate.CanonicalizeIP(ip), nil
},
httprate.WithLimitHandler(limitHandler))
})
}
// requestIDPattern is what a request's own X-Request-Id must look like to be
@@ -216,91 +210,14 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
})
}
// Metrics returns a Prometheus metrics middleware. Its handler label is the
// pattern of the chi route a request matches, such as /v1/image/*, or
// "unmatched" for a request that matches none, so the routes fix how many
// values the label has. It is never the path, with which any client could
// add series to the registry without limit. Its method label is the
// request's method when that is a standard one, such as GET, and "other"
// for any other: Go's HTTP server accepts any method name, so a label per
// method would let a client add series the same way.
// Metrics returns a Prometheus metrics middleware.
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{
Registry: s.metricsRegistry,
}),
Recorder: metrics.NewRecorder(metrics.Config{}),
})
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The metrics library takes the method label from the request
// it is handed, so it is handed a shallow copy of r whose
// method is the label. next is served r itself, through the
// response writer the library wraps to see the status and size.
labelled := r.WithContext(r.Context())
labelled.Method = methodLabel(r.Method)
serveNext := http.HandlerFunc(
func(wrapped http.ResponseWriter, _ *http.Request) {
next.ServeHTTP(wrapped, r)
})
std.Handler(routePattern(r), mdlw, serveNext).ServeHTTP(w, labelled)
})
}
}
// routePattern returns the pattern of the chi route r matches, or
// "unmatched" when it matches none or is not served by a chi router. The
// metrics library takes the label before the request is routed, so the
// router is asked which route matches, with the path it routes on:
// r.URL.RawPath when set, else r.URL.Path, else /.
func routePattern(r *http.Request) string {
pattern := ""
rctx := chi.RouteContext(r.Context())
if rctx != nil {
path := r.URL.RawPath
if path == "" {
path = r.URL.Path
}
if path == "" {
path = "/"
}
pattern = rctx.Routes.Find(chi.NewRouteContext(), r.Method, path)
// For the path a subrouter is mounted at, such as /v1 or /v1/, Find
// answers that path without asking the subrouter, which chi hands
// the request as /.
for _, route := range rctx.Routes.Routes() {
if route.SubRoutes != nil &&
route.Pattern == strings.TrimSuffix(pattern, "/")+"/*" &&
!route.SubRoutes.Match(chi.NewRouteContext(), r.Method, "/") {
pattern = ""
}
}
}
if pattern == "" {
return "unmatched"
}
return pattern
}
// methodLabel returns method when it is one of the standard methods net/http
// names: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT and TRACE.
// For any other method it returns "other".
func methodLabel(method string) string {
switch method {
case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete, http.MethodOptions,
http.MethodConnect, http.MethodTrace:
return method
default:
return "other"
return std.Handler("", mdlw, next)
}
}

Some files were not shown because too many files have changed in this diff Show More