Compare commits
37
Commits
83bea41607
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb5dc5d0d | ||
|
|
d8760c40a1 | ||
|
|
ca512fdb4f | ||
|
|
52f69d3082 | ||
|
|
162a2a33ef | ||
|
|
40ad39843b | ||
|
|
bdde021b45 | ||
|
|
db91ab29e6 | ||
|
|
8597253ffd | ||
|
|
99e4aa3bb2 | ||
|
|
d2944aa891 | ||
|
|
54328377d0 | ||
|
|
5058fd532b | ||
|
|
15d95436a5 | ||
|
|
01823d27db | ||
|
|
a941a80bf9 | ||
|
|
55cf7f4fac | ||
|
|
c434581a54 | ||
|
|
f77faf13de | ||
|
|
ae7c3f226d | ||
|
|
2beba15ae7 | ||
|
|
23ec4026f6 | ||
|
|
ef828f71a5 | ||
|
|
f3231a3c5a | ||
|
|
cca2e3f926 | ||
|
|
708a9bec20 | ||
|
|
8314099abd | ||
|
|
8568c17d1b | ||
|
|
625fd42ace | ||
|
|
66e71b4207 | ||
|
|
847ad5b428 | ||
|
|
233a9c05ad | ||
|
|
842372250f | ||
|
|
58d601ea48 | ||
|
|
48f21d4ecf | ||
|
|
f8c437b83f | ||
|
|
04093f53ad |
+70
-9
@@ -1,12 +1,73 @@
|
|||||||
# .git is sent without its config. Without a VERSION build argument the
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
# `/` 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.
|
||||||
|
|
||||||
|
# Unlike the standard file, which leaves out all of .git, pixa sends
|
||||||
|
# .git 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.
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
.git/config
|
.git/config
|
||||||
.gitignore
|
|
||||||
.DS_Store
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
.env*
|
# 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.
|
||||||
.claude
|
.claude
|
||||||
node_modules
|
|
||||||
bin/
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
data/
|
# 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][dD]25519
|
||||||
|
|
||||||
|
# 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. Nothing in the build reads .gitignore. On the
|
||||||
|
# host, `make build` writes bin/pixad, and the example config keeps its
|
||||||
|
# state directory in data/.
|
||||||
|
.gitignore
|
||||||
|
/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]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
||||||
|
# both sides instead of conflicting. Git never reports a conflict here: read
|
||||||
|
# the merged entries after every merge or rebase.
|
||||||
|
TODO.md merge=union
|
||||||
@@ -6,5 +6,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
# The default clone is shallow and has no tags, so the
|
||||||
|
# version the build takes from `git describe` would be a
|
||||||
|
# bare commit; this fetches the whole history with its tags.
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
- run: script/docker-smoke
|
- run: script/docker-smoke
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-*
|
*.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/
|
||||||
|
|
||||||
# Environment / secrets
|
# Environment / secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
@@ -31,5 +37,6 @@ node_modules/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# Local dev configs
|
# Local dev configs
|
||||||
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
config.dev.yml
|
config.dev.yml
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# A byte-for-byte copy of the one in sneak/prompts.
|
||||||
|
REPO_POLICIES.md
|
||||||
|
|
||||||
|
vendor/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -4,73 +4,68 @@ Last Updated 2026-01-08
|
|||||||
|
|
||||||
These rules MUST be followed at all times, it is very important.
|
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
|
- Never use `git add -A` - add specific changes to a deliberate commit. A commit
|
||||||
commit should contain one change. After each change, make a commit with a
|
should contain one change. After each change, make a commit with a good
|
||||||
good one-line summary.
|
one-line summary.
|
||||||
|
|
||||||
* NEVER modify the linter config without asking first.
|
- NEVER modify the linter config without asking first.
|
||||||
|
|
||||||
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
- 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,
|
without asking first. In almost all cases, the code should be changed, NOT the
|
||||||
NOT the tests. If you think the test needs to be changed, make your case
|
tests. If you think the test needs to be changed, make your case for that and
|
||||||
for that and ask for permission to proceed, then stop. You need explicit
|
ask for permission to proceed, then stop. You need explicit user approval to
|
||||||
user approval to modify existing tests. (You do not need user approval
|
modify existing tests. (You do not need user approval for writing NEW tests.)
|
||||||
for writing NEW tests.)
|
|
||||||
|
|
||||||
* When linting, assume the linter config is CORRECT, and that each item
|
- When linting, assume the linter config is CORRECT, and that each item output
|
||||||
output by the linter is something that legitimately needs fixing in the
|
by the linter is something that legitimately needs fixing in the code.
|
||||||
code.
|
|
||||||
|
|
||||||
* When running tests, use `make test`.
|
- When running tests, use `make test`.
|
||||||
|
|
||||||
* Before commits, run `make check`. This runs `make lint` and `make test`
|
- Before commits, run `make check`. This runs `make lint` and `make test` and
|
||||||
and `make check-fmt`. Any issues discovered MUST be resolved before
|
`make check-fmt`. Any issues discovered MUST be resolved before committing
|
||||||
committing unless explicitly told otherwise.
|
unless explicitly told otherwise.
|
||||||
|
|
||||||
* When fixing a bug, write a failing test for the bug FIRST. Add
|
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate
|
||||||
appropriate logging to the test to ensure it is written correctly. Commit
|
logging to the test to ensure it is written correctly. Commit that. Then go
|
||||||
that. Then go about fixing the bug until the test passes (without
|
about fixing the bug until the test passes (without modifying the test
|
||||||
modifying the test further). Then commit that.
|
further). Then commit that.
|
||||||
|
|
||||||
* When adding a new feature, do the same - implement a test first (TDD). It
|
- 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
|
doesn't have to be super complex. Commit the test, then commit the feature.
|
||||||
feature.
|
|
||||||
|
|
||||||
* When adding a new feature, use a feature branch. When the feature is
|
- 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`)
|
completely finished and the code is up to standards (passes `make check`) then
|
||||||
then and only then can the feature branch be merged into `main` and the
|
and only then can the feature branch be merged into `main` and the branch
|
||||||
branch deleted.
|
deleted.
|
||||||
|
|
||||||
* Write godoc documentation comments for all exported types and functions as
|
- Write godoc documentation comments for all exported types and functions as you
|
||||||
you go along.
|
go along.
|
||||||
|
|
||||||
* ALWAYS be consistent in naming. If you name something one thing in one
|
- ALWAYS be consistent in naming. If you name something one thing in one place,
|
||||||
place, name it the EXACT SAME THING in another place.
|
name it the EXACT SAME THING in another place.
|
||||||
|
|
||||||
* Be descriptive and specific in naming. `wl` is bad;
|
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
|
||||||
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
||||||
`MaxConnectionsPerHost` is good.
|
|
||||||
|
|
||||||
* This is not prototype or teaching code - this is designed for production.
|
- This is not prototype or teaching code - this is designed for production. Any
|
||||||
Any security issues (such as denial of service) or other web
|
security issues (such as denial of service) or other web vulnerabilities are
|
||||||
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
P1 bugs and must be added to TODO.md at the top.
|
||||||
|
|
||||||
* As this is production code, no stubbing of implementations unless
|
- As this is production code, no stubbing of implementations unless specifically
|
||||||
specifically instructed. We need working implementations.
|
instructed. We need working implementations.
|
||||||
|
|
||||||
* NEVER silently fall back to a different setting when a user's parameter
|
- 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
|
explicitly specifies a value. If a user requests format=webp and WebP encoding
|
||||||
encoding is not supported, return an error - do NOT silently output PNG
|
is not supported, return an error - do NOT silently output PNG instead. If a
|
||||||
instead. If a user specifies fit=invalid and that fit mode doesn't exist,
|
user specifies fit=invalid and that fit mode doesn't exist, return an error -
|
||||||
return an error - do NOT silently default to "cover". Silent fallbacks
|
do NOT silently default to "cover". Silent fallbacks violate the principle of
|
||||||
violate the principle of least surprise and mask bugs. The only acceptable
|
least surprise and mask bugs. The only acceptable defaults are for OMITTED
|
||||||
defaults are for OMITTED parameters, never for INVALID explicit values.
|
parameters, never for INVALID explicit values.
|
||||||
|
|
||||||
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
- Avoid vendoring deps unless specifically instructed to. NEVER commit the
|
||||||
the vendor directory, NEVER commit compiled binaries. If these
|
vendor directory, NEVER commit compiled binaries. If these directories or
|
||||||
directories or files exist, add them to .gitignore (and commit the
|
files exist, add them to .gitignore (and commit the .gitignore) if they are
|
||||||
.gitignore) if they are not already in there. Keep the entire git
|
not already in there. Keep the entire git repository (with history) small -
|
||||||
repository (with history) small - under 20MiB, unless you specifically
|
under 20MiB, unless you specifically must commit larger files (e.g. test
|
||||||
must commit larger files (e.g. test fixture example media files). Only
|
fixture example media files). Only OUR source code and immediately supporting
|
||||||
OUR source code and immediately supporting files (such as test examples)
|
files (such as test examples) goes into the repo/history.
|
||||||
goes into the repo/history.
|
|
||||||
|
|||||||
-1259
File diff suppressed because it is too large
Load Diff
+51
-35
@@ -1,55 +1,66 @@
|
|||||||
# Lint stage
|
# Lint phase. script/lint builds it alone. The linter is run directly:
|
||||||
# Same image as Dockerfile.lint: change both pins together.
|
# `make lint` and script/lint are themselves a docker build.
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
# golangci/golangci-lint:v2.12.2, 2026-10-04
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
|
# 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 golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase. script/test builds it alone.
|
||||||
|
# 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 test
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# script/bootstrap installs the build dependencies and downloads the Go
|
# script/bootstrap --cgo installs the build dependencies (a C compiler,
|
||||||
# modules. Only script/, go.mod and go.sum are copied first, so this
|
# the libvips and libheif headers, and libvips' JPEG XL support, which
|
||||||
# layer is reused until one of them changes.
|
# the tests need) and downloads the Go modules.
|
||||||
COPY script/ ./script/
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap --cgo
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
# Without -v first; on a failure, again with -v for the details, and
|
||||||
ENV container=docker
|
# the step fails even if the second run passes. -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 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; }
|
||||||
|
|
||||||
# Run formatting check and linter. script/cibuild and script/docker pass
|
# Build stage. Nothing is wanted from the two phases above: these copies
|
||||||
# a new CHECK_EPOCH on every run, and each check step names it in its
|
# make BuildKit build them first, so this stage runs only when lint and
|
||||||
# command, so a new value reruns the step instead of reusing a cached
|
# test passed.
|
||||||
# success that checked nothing. A plain `docker build .` leaves it empty
|
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
||||||
# and reuses the check steps only for an identical build context.
|
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||||
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
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Build dependencies and Go modules, as in the lint stage
|
# Build dependencies and Go modules, as in the test phase
|
||||||
COPY script/ ./script/
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap --cgo
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
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
|
# VERSION is declared here, not earlier: a new value reruns only the
|
||||||
# build, not script/bootstrap or the tests. Given none, the version is
|
# build, not script/bootstrap. Given none, the version is
|
||||||
# `git describe --tags --always` of the .git in the build context (git
|
# `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
|
# 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
|
# after one, the short commit when no tag is reachable. A context that
|
||||||
@@ -68,13 +79,18 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
-ldflags "-s -w -X main.Version=${version}" \
|
-ldflags "-s -w -X main.Version=${version}" \
|
||||||
-o /pixad ./cmd/pixad
|
-o /pixad ./cmd/pixad
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage, and the last one: a plain `docker build .` builds this
|
||||||
# alpine:3.21, 2026-02-25
|
# stage and what it depends on, and nothing else. It must use the Alpine
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
# 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
|
||||||
|
|
||||||
# Install runtime dependencies only
|
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
||||||
|
# support, without which pixad does not start.
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
vips \
|
vips \
|
||||||
|
vips-jxl \
|
||||||
libheif \
|
libheif \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata \
|
tzdata \
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
|
|
||||||
# which is never installed on the host. Pinned to the same image as the
|
|
||||||
# Dockerfile lint stage: change both pins together, or the two run
|
|
||||||
# different linter versions.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
|
||||||
# this image needs the same C libraries the build does. script/bootstrap
|
|
||||||
# installs them and downloads the Go modules. Only script/, go.mod and
|
|
||||||
# go.sum are copied first; they settle this layer's result, so it may
|
|
||||||
# safely be reused between runs.
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
|
||||||
ENV container=docker
|
|
||||||
|
|
||||||
# script/lint passes a different CACHEBUST on every run, and BuildKit
|
|
||||||
# keys every RUN after this ARG on its value, so the lint step always
|
|
||||||
# runs instead of returning a cached success that linted nothing.
|
|
||||||
#
|
|
||||||
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
|
|
||||||
# tmpfs that is discarded after the step. Written into the layer, they
|
|
||||||
# would pile up as build cache on every run, since no later run, with
|
|
||||||
# its new CACHEBUST, can reuse that layer.
|
|
||||||
ARG CACHEBUST
|
|
||||||
RUN --mount=type=tmpfs,target=/root/.cache script/lint
|
|
||||||
@@ -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
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
|
|
||||||
# Use nix-shell to provide CGO dependencies unless they are already available
|
# Use nix-shell to provide CGO dependencies unless they are already available
|
||||||
# (e.g. inside a Docker build or an existing nix-shell).
|
# (e.g. inside an existing nix-shell).
|
||||||
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
|
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
|
||||||
ifdef HAS_PKGCONFIG
|
ifdef HAS_PKGCONFIG
|
||||||
NIX_RUN_PREFIX =
|
NIX_RUN_PREFIX =
|
||||||
@@ -32,11 +32,11 @@ fmt-check:
|
|||||||
fmt:
|
fmt:
|
||||||
@script/fmt
|
@script/fmt
|
||||||
|
|
||||||
# Run linter
|
# Run linter (the lint phase of the Dockerfile)
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
# Run tests (30-second timeout)
|
# Run tests (the test phase of the Dockerfile)
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
@@ -59,20 +59,25 @@ docker:
|
|||||||
docker-smoke:
|
docker-smoke:
|
||||||
@script/docker-smoke
|
@script/docker-smoke
|
||||||
|
|
||||||
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
|
# Measure throughput, latency and peak memory with the default duration and
|
||||||
docker-versioned:
|
# number of clients (needs Docker and Go; a benchmark, not part of check)
|
||||||
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
loadtest:
|
||||||
|
@script/loadtest
|
||||||
|
|
||||||
# Run tests in Docker (needed for CGO/libvips)
|
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
||||||
|
docker-versioned:
|
||||||
|
@script/docker
|
||||||
|
docker tag pixa pixa:$(VERSION)
|
||||||
|
|
||||||
|
# Run tests in Docker, as `make test` does
|
||||||
docker-test:
|
docker-test:
|
||||||
docker build --target builder --build-arg VERSION=$(VERSION) -t pixad-builder .
|
@script/test
|
||||||
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
|
|
||||||
|
|
||||||
# Run local dev server in Docker
|
# Run local dev server in Docker
|
||||||
devserver: docker-versioned devserver-stop
|
devserver: docker-versioned devserver-stop
|
||||||
docker run -d --name pixad-dev -p 8080:8080 \
|
docker run -d --name pixad-dev -p 8080:8080 \
|
||||||
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
||||||
pixad:latest
|
pixa:latest
|
||||||
@echo "pixad running at http://localhost:8080"
|
@echo "pixad running at http://localhost:8080"
|
||||||
|
|
||||||
# Stop dev server
|
# Stop dev server
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# pixa
|
# pixa
|
||||||
|
|
||||||
pixa is a GPL-3.0-licensed Go web server by
|
pixa is a GPL-3.0-licensed Go web server by [@sneak](https://sneak.berlin) that
|
||||||
[@sneak](https://sneak.berlin) that proxies images from upstream
|
proxies images from upstream sources, optionally resizing or transforming them,
|
||||||
sources, optionally resizing or transforming them, and serves the
|
and serves the results. Both source and transformed images are cached to disk so
|
||||||
results. Both source and transformed images are cached to disk so that
|
that subsequent requests are served without origin fetches or additional
|
||||||
subsequent requests are served without origin fetches or additional
|
|
||||||
processing.
|
processing.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -18,7 +17,7 @@ make build
|
|||||||
# run with a config file: copy the example and set a real signing key
|
# run with a config file: copy the example and set a real signing key
|
||||||
# (the example placeholder is refused at startup), e.g. with
|
# (the example placeholder is refused at startup), e.g. with
|
||||||
# openssl rand -base64 32
|
# openssl rand -base64 32
|
||||||
cp config.example.yml config.yml
|
cp configs/config.example.yml config.yml
|
||||||
$EDITOR config.yml # replace the signing_key placeholder
|
$EDITOR config.yml # replace the signing_key placeholder
|
||||||
./bin/pixad --config config.yml
|
./bin/pixad --config config.yml
|
||||||
|
|
||||||
@@ -27,12 +26,11 @@ make docker
|
|||||||
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
A container takes its settings from environment variables (see
|
A container takes its settings from environment variables (see Configuration
|
||||||
Configuration below for the list). Only `PIXA_SIGNING_KEY` is required; if
|
below for the list). Only `PIXA_SIGNING_KEY` is required; if it is unset the
|
||||||
it is unset the container exits at startup naming the variable. Everything
|
container exits at startup naming the variable. Everything else has a built-in
|
||||||
else has a built-in default. A config file mounted at `/etc/pixa/config.yml`
|
default. A config file mounted at `/etc/pixa/config.yml` is optional: it is read
|
||||||
is optional: it is read when present, and an environment variable wins over
|
when present, and an environment variable wins over the same setting in it.
|
||||||
the same setting in it.
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -45,11 +43,12 @@ everything in this list without further settings. The reverse proxy must:
|
|||||||
Routes);
|
Routes);
|
||||||
- pass the `Host`, `Origin` and `Referer` headers on unchanged, as pixa refuses
|
- pass the `Host`, `Origin` and `Referer` headers on unchanged, as pixa refuses
|
||||||
a form from those pages unless `Origin` or `Referer` names the host in `Host`,
|
a form from those pages unless `Origin` or `Referer` names the host in `Host`,
|
||||||
and builds encrypted URLs from `Host`;
|
builds encrypted URLs from `Host`, and checks `Referer` against
|
||||||
|
`referer_blocklist`;
|
||||||
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
||||||
the address pixa sees the proxy's requests come from, so the login limit
|
the address pixa sees the proxy's requests come from, so the login limit and
|
||||||
counts each client by its own address (see `trusted_proxies` under
|
`image_requests_per_minute` count each client by its own address (see
|
||||||
Configuration);
|
`trusted_proxies` under Configuration);
|
||||||
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
||||||
longest pixa takes to fetch, convert and send an image.
|
longest pixa takes to fetch, convert and send an image.
|
||||||
|
|
||||||
@@ -59,8 +58,9 @@ password there.
|
|||||||
|
|
||||||
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
||||||
allowlist, refuses upstream hosts with private or local addresses, limits login
|
allowlist, refuses upstream hosts with private or local addresses, limits login
|
||||||
attempts, upstream response size and image dimensions, and sends the security
|
attempts, each client's image requests, upstream response size and image
|
||||||
headers, `Strict-Transport-Security` included, with every response.
|
dimensions, and sends the security headers, `Strict-Transport-Security`
|
||||||
|
included, with every response.
|
||||||
|
|
||||||
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
||||||
database and the disk cache:
|
database and the disk cache:
|
||||||
@@ -74,56 +74,64 @@ database and the disk cache:
|
|||||||
and files still being written come on top, and eviction runs in the
|
and files still being written come on top, and eviction runs in the
|
||||||
background, so the cache can pass the limit for a while: leave room on the
|
background, so the cache can pass the limit for a while: leave room on the
|
||||||
volume beyond it.
|
volume beyond it.
|
||||||
- Set `cache_max_bytes` for a lasting deployment. Its default is 75% of the
|
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
||||||
space free when pixa starts, which the cache's own files reduce, so a fuller
|
time pixa starts, is 75% of the sum of the space free on the volume and the
|
||||||
cache gives a smaller limit after a restart.
|
space the cached images already take, so a restart keeps the limit the cache
|
||||||
|
had, but anything else that fills or frees space on the volume moves it.
|
||||||
|
|
||||||
A load balancer's health check can request `/.well-known/healthcheck.json`,
|
A load balancer's health check can request `/.well-known/healthcheck.json`,
|
||||||
which answers 200 whenever pixa is running, in maintenance mode too (see
|
which answers 200 whenever pixa is running, in maintenance mode too (see
|
||||||
`maintenance_mode`).
|
`maintenance_mode`).
|
||||||
|
|
||||||
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
||||||
progress and the images being processed 5 seconds to finish, and exits: with 0,
|
progress and the images being processed 5 seconds to finish, then writes to the
|
||||||
or with 1 when images were still being processed after those 5 seconds or
|
database the counts of cache hits, misses, fetches and conversions that requests
|
||||||
another part of pixa failed to stop. A request not finished by then is cut off.
|
could not write by their deadline, and exits: with 0, or with 1 when images were
|
||||||
`docker stop` waits 10 seconds before it kills the container.
|
still being processed after those 5 seconds, some of those counts could not be
|
||||||
|
written, or another part of pixa failed to stop. A request not finished by then
|
||||||
|
is cut off. `docker stop` waits 10 seconds before it kills the container.
|
||||||
|
|
||||||
Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
|
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
|
||||||
it uses libvips through CGO; building it also needs their development files,
|
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
||||||
`pkg-config` and a C compiler. `script/bootstrap` installs all of these with
|
support, which on Alpine is the `vips-jxl` package and which the nix and brew
|
||||||
nix, apt, brew or apk.
|
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
|
||||||
|
on. Building pixa also needs the development files of libvips and libheif,
|
||||||
|
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these,
|
||||||
|
as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which
|
||||||
|
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
|
||||||
|
Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but
|
||||||
|
none of the C libraries: the checks compile pixa in Docker. Docker itself must
|
||||||
|
already be installed.
|
||||||
|
|
||||||
## Running under upaas
|
## Running under upaas
|
||||||
|
|
||||||
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||||
|
|
||||||
- **Port:** pixa listens on container port `8080`.
|
- **Port:** pixa listens on container port `8080`.
|
||||||
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its
|
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its database and
|
||||||
database and cache. Creating the host directory when it is missing is
|
cache. Creating the host directory when it is missing is upaas's job, tracked
|
||||||
upaas's job, tracked in https://git.eeqj.de/sneak/upaas/issues/235.
|
in https://git.eeqj.de/sneak/upaas/issues/235.
|
||||||
- **Environment variables:**
|
- **Environment variables:**
|
||||||
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs
|
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs and
|
||||||
and login, 32+ characters, for example from
|
login, 32+ characters, for example from `openssl rand -base64 32`
|
||||||
`openssl rand -base64 32`
|
|
||||||
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
||||||
comma-separated
|
comma-separated
|
||||||
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
||||||
default 75% of free space
|
default 75% of (free space + what the cache holds)
|
||||||
- the rest are in the table under Configuration below
|
- the rest are in the table under Configuration below
|
||||||
- **Health check:** the image's `HEALTHCHECK` requests
|
- **Health check:** the image's `HEALTHCHECK` requests
|
||||||
`/.well-known/healthcheck.json`. upaas reads the container's health 60
|
`/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds
|
||||||
seconds after a deploy and marks the deploy failed unless it is
|
after a deploy and marks the deploy failed unless it is `healthy`. The probe
|
||||||
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
|
uses the port from `PORT` (default `8080`), so a port changed only in a
|
||||||
port changed only in a mounted config file is not seen by it: change
|
mounted config file is not seen by it: change the port with `PORT`.
|
||||||
the port with `PORT`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Image-heavy web applications need a fast, caching reverse proxy that
|
Image-heavy web applications need a fast, caching reverse proxy that can resize
|
||||||
can resize and transcode images on the fly. pixa fills that role as a
|
and transcode images on the fly. pixa fills that role as a single,
|
||||||
single, self-contained binary with no external runtime dependencies
|
self-contained binary with no external runtime dependencies beyond libvips. It
|
||||||
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
supports HMAC-SHA256 signed URLs with expiration to prevent abuse, and
|
||||||
prevent abuse, and allowlisted source hosts for open access.
|
allowlisted source hosts for open access.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -132,8 +140,8 @@ prevent abuse, and allowlisted source hosts for open access.
|
|||||||
- **Source content**:
|
- **Source content**:
|
||||||
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
|
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
|
||||||
- **Source metadata**:
|
- **Source metadata**:
|
||||||
`<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json`
|
`<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json` (host,
|
||||||
(host, path and query, content hash, upstream status and headers, fetch time)
|
path and query, content hash, upstream status and headers, fetch time)
|
||||||
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
|
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
|
||||||
- **Transformed images**:
|
- **Transformed images**:
|
||||||
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`,
|
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`,
|
||||||
@@ -142,12 +150,13 @@ prevent abuse, and allowlisted source hosts for open access.
|
|||||||
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
|
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
|
||||||
name.
|
name.
|
||||||
|
|
||||||
Multiple source paths may reference the same content blob; the
|
Multiple source paths may reference the same content blob; the database tracks
|
||||||
database tracks references rather than using filesystem refcounting.
|
references rather than using filesystem refcounting.
|
||||||
Toward a target of 1-5k r/s, pixa keeps in memory the content types of
|
|
||||||
the 10,000 transformed images most recently cached or served, so a
|
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||||
cache hit on one of them reads only the image file from disk and not
|
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
||||||
the metadata file stored beside it.
|
transformed images most recently cached or served, so a cache hit on one of them
|
||||||
|
reads only the image file from disk and not the metadata file stored beside it.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -157,8 +166,8 @@ answers any method as it answers `GET`. A browser's CORS preflight request
|
|||||||
(`OPTIONS` with `Origin` and `Access-Control-Request-Method` headers) to any
|
(`OPTIONS` with `Origin` and `Access-Control-Request-Method` headers) to any
|
||||||
path under `/v1/` answers 200, in maintenance mode too.
|
path under `/v1/` answers 200, in maintenance mode too.
|
||||||
|
|
||||||
- `GET /` — the login page, or the URL generator page with a login session
|
- `GET /` — the login page, or the URL generator page with a login session (see
|
||||||
(see Encrypted URLs). Needs: nothing. Answers: 200.
|
Encrypted URLs). Needs: nothing. Answers: 200.
|
||||||
- `POST /` — log in with the signing key typed into the login page. Needs: the
|
- `POST /` — log in with the signing key typed into the login page. Needs: the
|
||||||
login page's form (below). Answers: 303 to `/` with a login session cookie
|
login page's form (below). Answers: 303 to `/` with a login session cookie
|
||||||
that lasts 30 days for the right key; 200 with the login page and an error for
|
that lasts 30 days for the right key; 200 with the login page and an error for
|
||||||
@@ -169,30 +178,39 @@ path under `/v1/` answers 200, in maintenance mode too.
|
|||||||
with the page naming a field that is not valid; 500 when the URL cannot be
|
with the page naming a field that is not valid; 500 when the URL cannot be
|
||||||
made.
|
made.
|
||||||
- `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`.
|
- `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`.
|
||||||
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>` — an image, fetched,
|
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
|
||||||
resized and converted (below). Needs: a signature, unless the host is
|
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
|
||||||
allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches
|
and converted (below). Needs: a signature, unless the host is allowlisted (see
|
||||||
the image's `ETag`; 400 for a URL or parameter that is not valid; 401 for a
|
Source Hosts). Answers: 200; 304 when `If-None-Match` matches the image's
|
||||||
missing or wrong signature, a missing `exp` or an `exp` in the past; 403 when
|
`ETag`; 400 for a URL or parameter that is not valid, or for the format `auto`
|
||||||
the upstream host, or a host it redirects to, is `localhost`, ends in
|
an `Accept` header that is not valid; 406 for the format `auto` when `Accept`
|
||||||
`.localhost` or `.local`, or has an address in a blocked network (see
|
allows none of the formats it chooses from; 401 for a missing or wrong
|
||||||
`blocked_networks`); 502 when the upstream answered with an error status, and
|
signature, a missing `exp` or an `exp` in the past; 403 when the request's
|
||||||
for 5 minutes after that for the same source URL; 503 when pixa is busy or in
|
`Referer` names a host in `referer_blocklist`, checked before the signature,
|
||||||
maintenance mode; 500 for any other failure.
|
the cache and the upstream fetch; 403 when the upstream host, or a host it
|
||||||
|
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
||||||
|
address in a blocked network (see `blocked_networks`); 429 over
|
||||||
|
`image_requests_per_minute` (below); 415 when the upstream answers with a
|
||||||
|
`Content-Type` that is not one of the source formats (below), or with an image
|
||||||
|
whose first bytes libvips reads as another format; 502 when the upstream
|
||||||
|
answered with an error status, and for 5 minutes after that for the same
|
||||||
|
source URL; 503 when pixa is busy or in maintenance mode; 500 for any other
|
||||||
|
failure.
|
||||||
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||||
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
||||||
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
||||||
decrypt, or that asks for a size or fit that is not valid; 410 once it has
|
decrypt, or that asks for a size or fit that is not valid; 410 once it has
|
||||||
expired; 504 when the upstream has not sent its response headers within
|
expired; 504 when the upstream has not sent its response headers within
|
||||||
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
||||||
itself is still arriving; 403, 502, 503 and 500 as for `/v1/image/`.
|
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
||||||
|
406, 403, 415, 429, 502, 503 and 500, as for `/v1/image/`.
|
||||||
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
||||||
nothing. Answers: 200.
|
nothing. Answers: 200.
|
||||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||||
`uptime_seconds`, `uptime_human`, `version`, `appname` and
|
`uptime_seconds`, `uptime_human`, `version`, `appname` and `maintenance_mode`.
|
||||||
`maintenance_mode`. Needs: nothing. Answers: 200, always.
|
Needs: nothing. Answers: 200, always.
|
||||||
- `GET /static/<file>` — the script the login and generator pages load. Needs:
|
- `GET /static/<file>` — the stylesheet and script the login and generator pages
|
||||||
nothing. Answers: 200, or 404 for a file that does not exist.
|
load. Needs: nothing. Answers: 200, or 404 for a file that does not exist.
|
||||||
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
|
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
|
||||||
authentication with `metrics.username` and `metrics.password`. Answers: 200;
|
authentication with `metrics.username` and `metrics.password`. Answers: 200;
|
||||||
401 without them; 404 when they are not set, as the route then does not exist.
|
401 without them; 404 when they are not set, as the route then does not exist.
|
||||||
@@ -216,66 +234,108 @@ HTTP is for development on the browser's own machine: the login session cookie
|
|||||||
is always marked `Secure`, and over plain HTTP a browser keeps such a cookie
|
is always marked `Secure`, and over plain HTTP a browser keeps such a cookie
|
||||||
only for its own machine (`localhost`), if at all. A form is also refused with
|
only for its own machine (`localhost`), if at all. A form is also refused with
|
||||||
403 when the page's host is not the `Host` header pixa receives, so a reverse
|
403 when the page's host is not the `Host` header pixa receives, so a reverse
|
||||||
proxy in front of pixa must pass that header on unchanged. A form body over
|
proxy in front of pixa must pass that header on unchanged. A form body over 1
|
||||||
1 MiB is refused with 413. The image routes answer the errors listed for them
|
MiB is refused with 413. The image routes answer the errors listed for them with
|
||||||
with JSON holding `error`, `status` and `timestamp`.
|
JSON holding `error`, `status` and `timestamp`.
|
||||||
|
|
||||||
An image URL has this form:
|
An image URL has one of these forms, the second with no format:
|
||||||
|
|
||||||
```
|
```
|
||||||
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
|
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
|
||||||
|
/v1/image/<host>/<path>/<size>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
|
||||||
```
|
```
|
||||||
|
|
||||||
Images are only fetched from origins using TLS with valid certificates, unless
|
Images are only fetched from origins using TLS with valid certificates, unless
|
||||||
`allow_http` is set: then pixa fetches every image over plain HTTP, which is for
|
`allow_http` is set: then pixa fetches every image over plain HTTP, which is for
|
||||||
testing only.
|
testing only.
|
||||||
|
|
||||||
A request whose query string cannot be decoded, or gives any parameter more
|
A request whose query string cannot be decoded, or gives any parameter more than
|
||||||
than once, is refused with 400.
|
once, is refused with 400.
|
||||||
|
|
||||||
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
|
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
|
||||||
`avif`, `gif`
|
`avif`, `jxl` (JPEG XL), `gif`, or `auto` (below). A URL with no format (the
|
||||||
|
second form, with no dot after the size) is served as JPEG XL, the default, as
|
||||||
|
with `jxl`
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
- `sig` and `exp`: the signature and its expiry, needed unless the host is
|
- `sig` and `exp`: the signature and its expiry, needed unless the host is
|
||||||
allowlisted (see Signature Specification)
|
allowlisted (see Signature Specification)
|
||||||
- `q` and `fit`: the output quality and how the image is fitted to `<size>`,
|
- `q` and `fit`: the output quality and how the image is fitted to `<size>`,
|
||||||
both optional (values under Signature Specification). Both are part of what
|
both optional (values under Signature Specification). Both are part of what is
|
||||||
is cached, so each value of either is a separate cached image.
|
cached, so each value of either is a separate cached image.
|
||||||
|
|
||||||
|
A size with one side `0` has that side scaled to keep the source's proportions,
|
||||||
|
and is refused with 400 when that side would come out above 8192, the most
|
||||||
|
either side may be; so is a size with `fit=outside`, which scales the image to
|
||||||
|
cover the size and so past it on one side, when that side would come out
|
||||||
|
above 8192.
|
||||||
|
|
||||||
|
The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
|
||||||
|
`Content-Type` must name its format, and the image's first bytes must match it.
|
||||||
|
An upstream answering with any other `Content-Type`, SVG included, is refused
|
||||||
|
with 415, and so is an image whose first bytes libvips reads as another format,
|
||||||
|
such as an SVG whose first bytes pass the check for AVIF.
|
||||||
|
|
||||||
|
With the format `auto`, pixa chooses the format for each request from its
|
||||||
|
`Accept` header, in this order:
|
||||||
|
|
||||||
|
1. JPEG XL, when the header names `image/jxl`;
|
||||||
|
2. AVIF, when it names `image/avif`;
|
||||||
|
3. WebP, when it names `image/webp`;
|
||||||
|
4. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names
|
||||||
|
allows it, or when there is no `Accept` header or it is empty.
|
||||||
|
|
||||||
|
An entry with `q=0` refuses its format; other `q` values do not change the
|
||||||
|
order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them
|
||||||
|
also send `image/*` and `*/*`. pixa never sends a format the client refused:
|
||||||
|
when the header allows none of the four, the answer is 406, and a header that
|
||||||
|
does not parse, or has a `q` that is not a number from 0 to 1, is refused
|
||||||
|
with 400. The signature, or the token of an encrypted URL, covers `auto` itself,
|
||||||
|
so one URL serves every client. Each format chosen is cached as a separate
|
||||||
|
image, and every answer that depends on `Accept` (the image, a 304, and the 400
|
||||||
|
and 406 above) carries `Vary: Accept`, so a shared cache keeps the formats apart
|
||||||
|
too.
|
||||||
|
|
||||||
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
||||||
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
|
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age`
|
||||||
`max-age` is the whole seconds left until then, at most one year, so no browser
|
is the whole seconds left until then, at most one year, so no browser or proxy
|
||||||
or proxy cache keeps the image after pixa would refuse the URL. A URL with no
|
cache keeps the image after pixa would refuse the URL. A URL with no expiry gets
|
||||||
expiry gets one year. `immutable` only stops a client revalidating while its
|
one year. `immutable` only stops a client revalidating while its copy is fresh.
|
||||||
copy is fresh.
|
|
||||||
|
|
||||||
When several requests for the same image, size, format, quality and fit miss
|
When several requests for the same image, size, format, quality and fit miss the
|
||||||
the cache at once, they share one upstream fetch (or one read of the cached
|
cache at once, they share one upstream fetch (or one read of the cached source)
|
||||||
source) and one transcode: the first request does the work, and the others wait
|
and one transcode: the first request does the work, and the others wait for its
|
||||||
for its image or its error, holding no upstream connection or processing slot
|
image or its error, holding no upstream connection or processing slot of their
|
||||||
of their own. A waiting request stops waiting when its own client goes away.
|
own. A waiting request stops waiting when its own client goes away. The work
|
||||||
The work goes on for the others even if the first request's client goes away,
|
goes on for the others even if the first request's client goes away, until that
|
||||||
until that request's `downstream_timeout` ends. The shared fetch sends the first
|
request's `downstream_timeout` ends. The shared fetch sends the first request's
|
||||||
request's ID upstream, and the lines logged for the fetch and the transcode
|
ID upstream, and the lines logged for the fetch and the transcode carry that ID.
|
||||||
carry that ID.
|
|
||||||
|
|
||||||
The login form (`POST /`) is limited to 5 attempts per minute per client
|
The login form (`POST /`) is limited to 5 attempts per minute per client
|
||||||
address, counting an IPv6 client by its /64; an attempt over the limit is
|
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||||
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
address comes from `X-Forwarded-For` only when the address pixa sees for
|
address comes from `X-Forwarded-For` only when the address pixa sees for
|
||||||
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
||||||
users behind the proxy are counted as one client. That address is not always
|
users behind the proxy are counted as one client. That address is not always the
|
||||||
the proxy's own: a proxy on the Docker host that connects to pixa over
|
proxy's own: a proxy on the Docker host that connects to pixa over `127.0.0.1`
|
||||||
`127.0.0.1` is seen as the gateway of the container's Docker network, such as
|
is seen as the gateway of the container's Docker network, such as `172.17.0.1`
|
||||||
`172.17.0.1` on the default bridge, and one that connects through another of the
|
on the default bridge, and one that connects through another of the host's
|
||||||
host's addresses is seen with that address. To be sure, read it as `remoteIP` in
|
addresses is seen with that address. To be sure, read it as `remoteIP` in pixa's
|
||||||
pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies`
|
request log while it is not in `trusted_proxies` (see `trusted_proxies` under
|
||||||
under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges),
|
Configuration). With the default `trusted_proxies` (the RFC 1918 ranges), a
|
||||||
a client with a private address can choose the address it is counted by through
|
client with a private address can choose the address it is counted by through
|
||||||
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
|
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
|
||||||
because its own address is trusted too. Setting `trusted_proxies` to only the
|
because its own address is trusted too. Setting `trusted_proxies` to only the
|
||||||
address pixa sees for requests that come through the proxy closes this.
|
address pixa sees for requests that come through the proxy closes this.
|
||||||
|
|
||||||
|
One client may send `image_requests_per_minute` requests per minute
|
||||||
|
(default 600) to the image routes, `/v1/image/` and `/v1/e/` together, `GET` and
|
||||||
|
`HEAD`, cache hits included; a request over the limit is refused with 429 and a
|
||||||
|
`Retry-After` header. Clients are counted as for the login limit. A browser's
|
||||||
|
CORS preflight is not counted. Behind a CDN or proxy whose addresses are not in
|
||||||
|
`trusted_proxies`, all requests arriving from one proxy or CDN address count as
|
||||||
|
one client, so such a deployment needs a higher limit, or `0`, which turns the
|
||||||
|
limit off.
|
||||||
|
|
||||||
### Encrypted URLs
|
### Encrypted URLs
|
||||||
|
|
||||||
An encrypted URL is an image URL made on pixa's own web page by someone who
|
An encrypted URL is an image URL made on pixa's own web page by someone who
|
||||||
@@ -288,26 +348,29 @@ nor change what it asks for.
|
|||||||
lasts 30 days, or until `/logout`.
|
lasts 30 days, or until `/logout`.
|
||||||
2. On the generator page, give the source image's URL, the width and height, the
|
2. On the generator page, give the source image's URL, the width and height, the
|
||||||
format, quality and fit, and how long the URL lasts, then submit the form
|
format, quality and fit, and how long the URL lasts, then submit the form
|
||||||
(`POST /generate`). Width and height both empty or `0` keep the original
|
(`POST /generate`). The format is JPEG XL unless another is chosen; a form
|
||||||
size; if only one of them is empty or `0`, that side is scaled to keep the
|
sent with an empty format, or none, also makes a JPEG XL URL. Width and
|
||||||
image's proportions.
|
height both empty or `0` keep the original size; if only one of them is empty
|
||||||
|
or `0`, that side is scaled to keep the image's proportions. A URL whose
|
||||||
|
scaled side would come out above 8192 answers 400.
|
||||||
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
||||||
it expires. `<host>` is the host the page was opened on, and the URL starts
|
it expires. `<host>` is the host the page was opened on, and the URL starts
|
||||||
with `http` instead while `debug` is on. The name after the token is ignored
|
with `http` instead while `debug` is on. The name after the token is ignored
|
||||||
and only gives the URL a file extension, `jpg` for `orig`.
|
and only gives the URL a file extension, `jpg` for `orig` and `auto`, and
|
||||||
|
`jxl` for a form with no format.
|
||||||
|
|
||||||
The token holds the source's host, path and query and the size, format,
|
The token holds the source's host, path and query and the size, format, quality,
|
||||||
quality, fit and expiry, encrypted with a key derived from `signing_key`. The
|
fit and expiry, encrypted with a key derived from `signing_key`. The source
|
||||||
source URL's scheme is not kept: the image is fetched like any other (see
|
URL's scheme is not kept: the image is fetched like any other (see Routes), and
|
||||||
Routes), and the blocked networks still apply.
|
the blocked networks still apply.
|
||||||
|
|
||||||
How long the URL lasts is chosen on the page, from 1 minute to 1 year, or
|
How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never.
|
||||||
never. The expiry is fixed in the token when the URL is made and cannot be
|
The expiry is fixed in the token when the URL is made and cannot be changed or
|
||||||
changed or revoked afterwards. Until then the image is served with a `max-age`
|
revoked afterwards. Until then the image is served with a `max-age` that ends at
|
||||||
that ends at the expiry (see Routes); after it the URL answers 410
|
the expiry (see Routes); after it the URL answers 410 `URL has expired`. A URL
|
||||||
`URL has expired`. A URL made to last forever stops working only when
|
made to last forever stops working only when `signing_key` changes: changing it
|
||||||
`signing_key` changes: changing it makes every encrypted URL already handed out
|
makes every encrypted URL already handed out answer 400, and ends every login
|
||||||
answer 400, and ends every login session.
|
session.
|
||||||
|
|
||||||
### Image Metadata
|
### Image Metadata
|
||||||
|
|
||||||
@@ -323,19 +386,22 @@ turned off.
|
|||||||
- An image with an ICC profile is converted to sRGB first, since clients show an
|
- An image with an ICC profile is converted to sRGB first, since clients show an
|
||||||
image with no profile as sRGB. Colours outside sRGB, such as the most
|
image with no profile as sRGB. Colours outside sRGB, such as the most
|
||||||
saturated ones in a Display P3 photo, are clipped.
|
saturated ones in a Display P3 photo, are clipped.
|
||||||
|
- The one exception is JPEG XL with libvips 8.16 and later: libvips then writes
|
||||||
|
an EXIF block of its own into the image, as govips cannot ask libvips to leave
|
||||||
|
it out. It holds the image's size and otherwise fixed values, such as an
|
||||||
|
orientation of 1 and a resolution of 72 dpi, and nothing from the source.
|
||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
Source hosts may be allowlisted in the configuration. Non-allowlisted hosts
|
||||||
hosts require an HMAC-SHA256 signature.
|
require an HMAC-SHA256 signature.
|
||||||
|
|
||||||
#### Signature Specification
|
#### Signature Specification
|
||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
Signatures use HMAC-SHA256 and include an expiration timestamp to prevent replay
|
||||||
prevent replay attacks. Signatures are **exact match only**: every
|
attacks. Signatures are **exact match only**: every component (host, path,
|
||||||
component (host, path, query, dimensions, format, expiration, quality,
|
query, dimensions, format, expiration, quality, fit) must match exactly what was
|
||||||
fit) must match exactly what was signed. No suffix matching, wildcard
|
signed. No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
matching, or partial matching is supported.
|
|
||||||
|
|
||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
@@ -351,25 +417,27 @@ Where:
|
|||||||
- `width` — requested width in pixels, `0` for original
|
- `width` — requested width in pixels, `0` for original
|
||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format, one of those listed under Routes, with `original`
|
- `format` — output format, one of those listed under Routes, with `original`
|
||||||
signed as `orig` and `jpg` as `jpeg`
|
signed as `orig`, `jpg` as `jpeg`, and no format as `jxl`, so a URL with no
|
||||||
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when
|
format has the signature of the same URL ending in `.jxl`; `auto` is signed as
|
||||||
the signature expires; a request whose `exp` is not a whole number, an
|
`auto`, not as the format chosen for the request
|
||||||
empty `exp=` included, is refused with 400
|
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when the
|
||||||
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100,
|
signature expires; a request whose `exp` is not a whole number, an empty
|
||||||
or `85` when the URL has no `q`; a request whose `q` is anything else is
|
`exp=` included, is refused with 400
|
||||||
refused with 400
|
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100, or
|
||||||
|
`85` when the URL has no `q`; a request whose `q` is anything else is refused
|
||||||
|
with 400
|
||||||
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
||||||
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
||||||
anything else, an empty `fit=` included, is refused with 400
|
anything else, an empty `fit=` included, is refused with 400
|
||||||
|
|
||||||
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
|
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet of
|
||||||
of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
||||||
compares it exactly, so a signature encoded without padding, as Node's
|
compares it exactly, so a signature encoded without padding, as Node's
|
||||||
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
|
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
|
||||||
|
|
||||||
**Example:** with the signing key `example-signing-key-for-documentation`,
|
**Example:** with the signing key `example-signing-key-for-documentation`,
|
||||||
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with expiration
|
||||||
expiration 1704067200, default quality and fit:
|
1704067200, default quality and fit:
|
||||||
|
|
||||||
1. Build input:
|
1. Build input:
|
||||||
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
||||||
@@ -390,33 +458,38 @@ and the URL is
|
|||||||
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
||||||
`images.example.com`, and `example.com`
|
`images.example.com`, and `example.com`
|
||||||
|
|
||||||
|
An IP address is matched exactly; write an IPv6 address without brackets. An
|
||||||
|
entry that is neither a host name (letters, digits, hyphens, underscores and
|
||||||
|
dots, with at most one leading dot) nor an IP address, such as one with a port
|
||||||
|
or a `*.` wildcard, aborts startup.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Every setting can be given as an environment variable, in a YAML config
|
Every setting can be given as an environment variable, in a YAML config file
|
||||||
file (`--config`), or both. A variable present in the environment wins over
|
(`--config`), or both. A variable present in the environment wins over the file,
|
||||||
the file, even when it is empty, and the file wins over the built-in
|
even when it is empty, and the file wins over the built-in default. The one
|
||||||
default. The one exception is a variable named in the file's `env:` section:
|
exception is a variable named in the file's `env:` section: it is set while the
|
||||||
it is set while the file loads, so it overrides both the environment the
|
file loads, so it overrides both the environment the process was started with
|
||||||
process was started with and the file's own key. A variable's value is
|
and the file's own key. A variable's value is parsed as the same text in the
|
||||||
parsed as the same text in the file would be. The three lists take
|
file would be. The three lists take comma-separated entries, with the spaces
|
||||||
comma-separated entries, with the spaces around each trimmed; an empty
|
around each trimmed; an empty variable is an empty list. A value that does not
|
||||||
variable is an empty list. A value that does not parse or is invalid aborts
|
parse or is invalid aborts startup, naming the variable. A variable whose name
|
||||||
startup, naming the variable. A variable whose name starts with `PIXA_` but
|
starts with `PIXA_` but is not in the table below, such as a misspelled one or
|
||||||
is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts
|
`PIXA_PORT`, aborts startup naming it, as an unknown config key does. The one
|
||||||
startup naming it, as an unknown config key does. The one other accepted
|
other accepted name is `PIXA_CONFIG_PATH`, the config file's path (like
|
||||||
name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The
|
`--config`). The variables set by the file's `env:` section are checked the same
|
||||||
variables set by the file's `env:` section are checked the same way.
|
way.
|
||||||
|
|
||||||
pixa reads at most one config file: the one given with `--config` (or `-c`),
|
pixa reads at most one config file: the one given with `--config` (or `-c`),
|
||||||
otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that
|
otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that
|
||||||
pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
|
pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
|
||||||
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
|
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml` and
|
||||||
and `config.yaml` in the working directory. A named file that does not exist,
|
`config.yaml` in the working directory. A named file that does not exist, cannot
|
||||||
cannot be read or does not parse aborts startup. Of the files pixa looks for on
|
be read or does not parse aborts startup. Of the files pixa looks for on its
|
||||||
its own, one it finds but cannot read or parse aborts startup; one it cannot
|
own, only one that does not exist is passed over, without a message. One that
|
||||||
find, for any reason, is passed over without a message, even when the file is
|
pixa cannot read or parse aborts startup, naming the file. So does one in a
|
||||||
there in a directory pixa may not enter. With no file, pixa uses the environment
|
directory pixa may not enter, whether or not it is there, since pixa cannot
|
||||||
and the defaults.
|
tell. With no file, pixa uses the environment and the defaults.
|
||||||
|
|
||||||
| Variable | Config key | Meaning |
|
| Variable | Config key | Meaning |
|
||||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
@@ -424,14 +497,16 @@ and the defaults.
|
|||||||
| `PORT` | `port` | Port to listen on; default `8080` |
|
| `PORT` | `port` | Port to listen on; default `8080` |
|
||||||
| `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
|
| `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
|
||||||
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
|
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
|
||||||
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of free space |
|
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of (free + cached) |
|
||||||
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
|
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
|
||||||
|
| `PIXA_REFERER_BLOCKLIST` | `referer_blocklist` | Hosts whose pages the image routes refuse with 403, by `Referer` |
|
||||||
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
|
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
|
||||||
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
||||||
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
||||||
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||||
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
||||||
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
||||||
|
| `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
|
||||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||||
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||||
@@ -448,49 +523,67 @@ Key settings in more detail:
|
|||||||
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
|
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
|
||||||
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
|
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
|
||||||
default, is any site; otherwise one `http` or `https` origin such as
|
default, is any site; otherwise one `http` or `https` origin such as
|
||||||
`https://example.com`, whose host is a lowercase host name (letters,
|
`https://example.com`, whose host is a lowercase host name (letters, digits,
|
||||||
digits, hyphens and dots, with a letter in its last part) or an IP address
|
hyphens and dots, with a letter in its last part) or an IP address (IPv6 in
|
||||||
(IPv6 in brackets, in its shortest form), with an optional port 1-65535
|
brackets, in its shortest form), with an optional port 1-65535 that has no
|
||||||
that has no leading zero and is not the scheme's default. Any other value,
|
leading zero and is not the scheme's default. Any other value, including
|
||||||
including another scheme such as a browser extension's, aborts startup
|
another scheme such as a browser extension's, aborts startup
|
||||||
- `allowlist_hosts` — list of allowed upstream hosts
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
- `referer_blocklist` — list of hosts whose pages may not show pixa's images, to
|
||||||
added to the always-enforced built-in ranges (loopback, private,
|
stop other sites hotlinking them. Entries are written and matched as for
|
||||||
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
|
`allowlist_hosts` (see Allowlist patterns), and an entry that is neither a
|
||||||
aborts startup
|
host name nor an IP address aborts startup. A request to `/v1/image/` or
|
||||||
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front
|
`/v1/e/` whose `Referer` header names a listed host is refused with 403 before
|
||||||
of pixa. `X-Forwarded-For` is believed only when the direct peer falls
|
its signature or token is checked and before the cache or the upstream host is
|
||||||
inside one of these ranges; the logged and login-recorded client
|
used, so it fetches nothing, and it is refused even when the image is cached.
|
||||||
address is then the rightmost forwarded entry that is not itself a
|
A request with no `Referer`, or one that does not parse as a URL with a host,
|
||||||
trusted proxy. Otherwise the direct peer address is used and the header
|
is served, as many clients send none. So this is easily got around: a site
|
||||||
is ignored, so a client connecting directly from an address outside
|
whose pages send no `Referer` (for example with
|
||||||
these ranges cannot spoof its address.
|
`Referrer-Policy: no-referrer`) is not stopped. It does not apply to the login
|
||||||
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
and generator pages. Default: empty
|
||||||
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
|
||||||
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
|
||||||
one, and an explicit list replaces the default. An invalid CIDR aborts
|
benchmark, NAT64, and the like); an invalid CIDR aborts startup
|
||||||
startup. Set this to the address pixa sees for requests that come through
|
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
|
||||||
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
|
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
|
||||||
to trust nothing else (see the login limit under Routes). For a proxy on
|
of these ranges; the logged and login-recorded client address is then the
|
||||||
the Docker host that connects to pixa over `127.0.0.1`, that address is the
|
rightmost forwarded entry that is not itself a trusted proxy. Otherwise the
|
||||||
gateway of the container's Docker network (`172.17.0.1` on the default
|
direct peer address is used and the header is ignored, so a client connecting
|
||||||
|
directly from an address outside these ranges cannot spoof its address. An
|
||||||
|
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a
|
||||||
|
private network; an explicitly empty list (`[]`) trusts no one, and an
|
||||||
|
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
||||||
|
to the address pixa sees for requests that come through your proxy, such as
|
||||||
|
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
||||||
|
(see the login limit and `image_requests_per_minute` under Routes). For a
|
||||||
|
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
|
||||||
|
is the gateway of the container's Docker network (`172.17.0.1` on the default
|
||||||
bridge), not the proxy's own address; a proxy that connects through another of
|
bridge), not the proxy's own address; a proxy that connects through another of
|
||||||
the host's addresses is seen with that address. To be sure which address it
|
the host's addresses is seen with that address. To be sure which address it
|
||||||
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
||||||
through the proxy, and read `remoteIP` in pixa's request log line for it
|
through the proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||||
host, as a duration such as `30s` (the default) or `2m`
|
as a duration such as `30s` (the default) or `2m`
|
||||||
- `upstream_max_response_size` — largest upstream response accepted, in
|
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||||
bytes; default `52428800` (50 MiB). It also limits the image data pixa
|
default `52428800` (50 MiB). It also limits the image data pixa decodes
|
||||||
decodes
|
|
||||||
- `downstream_timeout` — time allowed for answering one client request, as a
|
- `downstream_timeout` — time allowed for answering one client request, as a
|
||||||
duration; default `60s`. The upstream fetch counts toward it, and so do the
|
duration; default `60s`. The upstream fetch counts toward it, and so do the
|
||||||
waits for an upstream connection and for a processing slot (up to 10 seconds
|
waits for an upstream connection and for a processing slot (up to 10 seconds
|
||||||
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
||||||
- `signing_key` — HMAC secret for URL signatures
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
- `db_url` — the SQLite database to open; omitted, it is
|
||||||
disk cache entirely; omitted defaults to 75% of the free space on
|
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||||
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
database in WAL mode. pixa opens one connection to it, so its own reads and
|
||||||
|
writes run one at a time. pixa adds `_pragma=busy_timeout(5000)` to any
|
||||||
|
`db_url`, so a write that finds another program writing to the file waits up
|
||||||
|
to five seconds for it instead of failing. WAL mode comes only from the URL:
|
||||||
|
keep `_pragma=journal_mode(WAL)` in one you set
|
||||||
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk
|
||||||
|
cache entirely; omitted defaults to 75% of the sum of the free space on the
|
||||||
|
filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||||
|
transformed images the cache already holds, worked out at startup (minimum 500
|
||||||
|
MiB)
|
||||||
- `upstream_connections` — the most connections to upstream hosts at once, all
|
- `upstream_connections` — the most connections to upstream hosts at once, all
|
||||||
hosts together, on top of `upstream_connections_per_host`; default `64`. A
|
hosts together, on top of `upstream_connections_per_host`; default `64`. A
|
||||||
fetch holds its connection until its image has been processed. A fetch that
|
fetch holds its connection until its image has been processed. A fetch that
|
||||||
@@ -502,15 +595,21 @@ Key settings in more detail:
|
|||||||
container's CPU limit. A request that finds all of them in use waits up to 10
|
container's CPU limit. A request that finds all of them in use waits up to 10
|
||||||
seconds for one to free up; if none does, and `downstream_timeout` has not
|
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||||
ended first, it is answered 503 the same way
|
ended first, it is answered 503 the same way
|
||||||
|
- `image_requests_per_minute` — how many requests one client may send to the
|
||||||
|
image routes per minute (see the limit on them under Routes); default `600`.
|
||||||
|
`0` turns the limit off, and a negative or non-integer value aborts startup.
|
||||||
|
Behind a CDN or proxy whose addresses are not in `trusted_proxies`, all
|
||||||
|
requests arriving from one proxy or CDN address count as one client, so raise
|
||||||
|
the limit there, or set `0`
|
||||||
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||||
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
||||||
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
||||||
still answers 200 and reports `"maintenance_mode": true`. It stays 200
|
still answers 200 and reports `"maintenance_mode": true`. It stays 200 because
|
||||||
because the image's Docker `HEALTHCHECK` requests it: a 503 there would make
|
the image's Docker `HEALTHCHECK` requests it: a 503 there would make the
|
||||||
the container unhealthy, and upaas marks a deploy failed when its container
|
container unhealthy, and upaas marks a deploy failed when its container is
|
||||||
is unhealthy. The login and URL generator pages and `/metrics` keep working
|
unhealthy. The login and URL generator pages and `/metrics` keep working
|
||||||
|
|
||||||
See `config.example.yml` for all options with defaults.
|
See `configs/config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@@ -530,28 +629,98 @@ See `config.example.yml` for all options with defaults.
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
them. We provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (idempotent)
|
- `script/bootstrap` — install git, make, Go, Node, Yarn and prettier and
|
||||||
- `script/setup` — make a fresh clone ready for development
|
download the Go modules (idempotent); with `--cgo`, the C compiler and the
|
||||||
(bootstrap, then install-precommit)
|
libvips (with its JPEG XL support) and libheif libraries that compiling and
|
||||||
|
testing pixa need instead of Node, Yarn and prettier
|
||||||
|
- `script/setup` — make a fresh clone ready for development (bootstrap, then
|
||||||
|
install-precommit)
|
||||||
- `script/projectname` — output the project name ("pixa")
|
- `script/projectname` — output the project name ("pixa")
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite: build the `test` phase of the
|
||||||
- `script/lint` — run golangci-lint, always in a container (builds
|
`Dockerfile`, tagged `pixa-test`
|
||||||
`Dockerfile.lint` when run outside one)
|
- `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`,
|
||||||
- `script/fmt` — format all code (writes)
|
tagged `pixa-lint`; the linter never runs on the host
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt` — format the Go code with gofmt and the markdown with prettier
|
||||||
|
(writes)
|
||||||
|
- `script/fmt-check` — check the same formatting (read-only), on the host
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
||||||
|
the version from `git describe`; the image's build stage depends on the `lint`
|
||||||
|
and `test` phases, so this runs them too
|
||||||
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
||||||
- `script/cibuild` — CI entrypoint: `docker build .` with a new
|
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a
|
||||||
`CHECK_EPOCH` on every run, so the Dockerfile's checks run instead of
|
benchmark, not part of `script/check` (see Load Test)
|
||||||
coming from the build cache, and a green run implies a green repo
|
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`),
|
||||||
|
then `script/check`, then build the image as `script/docker` does
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
`script/check`)
|
`script/check`)
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||||
runs `script/precommit`
|
`script/precommit`
|
||||||
|
|
||||||
|
Every `docker build` in these scripts passes `--no-cache`, so the lint and test
|
||||||
|
phases run on every build instead of coming from the build cache.
|
||||||
|
`script/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`,
|
||||||
|
`script/setup` and `script/install-precommit` are the standard copies from
|
||||||
|
`sneak/prompts`, kept identical to them. `script/fmt` and `script/fmt-check` are
|
||||||
|
the standard copies with pixa's `gofmt` step kept before prettier. prettier
|
||||||
|
formats the markdown only: not the HTML templates, as it cannot parse a Go
|
||||||
|
template action inside a tag, and not `REPO_POLICIES.md` (see
|
||||||
|
`.prettierignore`), a copy of the one in `sneak/prompts`.
|
||||||
|
|
||||||
|
## Load Test
|
||||||
|
|
||||||
|
`script/loadtest` (or `make loadtest`) measures how fast pixad answers and how
|
||||||
|
much memory it uses. It is a benchmark, not a check: `script/check` does not run
|
||||||
|
it. It needs Docker and Go.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
script/loadtest # 10 seconds per scenario, 4 clients
|
||||||
|
script/loadtest 30s 32 # 30 seconds per scenario, 32 clients
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds the image with `script/docker` and the load tool,
|
||||||
|
[vegeta](https://github.com/tsenart/vegeta), from a pinned commit. Each scenario
|
||||||
|
starts a new pixad container and a new origin container, `cmd/loadtest-origin`:
|
||||||
|
an upstream host that answers every path with the same generated 1600x1200 JPEG.
|
||||||
|
vegeta then sends requests from the given number of clients, each sending its
|
||||||
|
next request as soon as its last one is answered, all for an image resized to
|
||||||
|
400x300 WebP:
|
||||||
|
|
||||||
|
- `hit`: the same image every time, put in the cache first;
|
||||||
|
- `miss`: a new source image every time, so pixad fetches and converts each one;
|
||||||
|
- `herd`: each new source image once per client in a row, so that all clients
|
||||||
|
ask for it at the same time and share one fetch and one conversion (see
|
||||||
|
Routes).
|
||||||
|
|
||||||
|
pixad refuses upstream hosts with private or local addresses, so the containers
|
||||||
|
share a Docker network in `203.0.113.0/24`, a range set aside for documentation.
|
||||||
|
A second run on the same Docker host while one is going fails, as it cannot
|
||||||
|
create that network.
|
||||||
|
|
||||||
|
For each scenario the script prints vegeta's report and two lines of its own:
|
||||||
|
|
||||||
|
- `Requests [total, rate, throughput]`: the requests sent, how many were sent
|
||||||
|
per second, and how many were answered successfully per second; the last is
|
||||||
|
the number to compare with the target under Storage;
|
||||||
|
- `Latencies [min, mean, 50, 90, 95, 99, max]`: the time from sending a request
|
||||||
|
to the end of its answer; `50`, `95` and `99` are the 50th, 95th and 99th
|
||||||
|
percentiles;
|
||||||
|
- `Status Codes` and `Error Set`: anything other than `200` means the other
|
||||||
|
numbers are not for the scenario described, such as `503` when pixad was busy;
|
||||||
|
- `Bytes In`: `0`, as vegeta is told not to keep the images it receives;
|
||||||
|
- `pixad peak memory (VmHWM)`: the peak resident memory of pixad's process since
|
||||||
|
its container started, in kB; for `hit` it includes the request that put the
|
||||||
|
image in the cache;
|
||||||
|
- `requests to the origin`: the fetches pixad made: one for `hit`, one per
|
||||||
|
request for `miss`, and one per image for `herd`, that is the requests sent
|
||||||
|
divided by the number of clients.
|
||||||
|
|
||||||
|
The numbers depend on the machine and on whatever else runs on it. The first
|
||||||
|
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
|
||||||
|
nothing about the target.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
|||||||
+270
-75
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-07-06
|
last_modified: 2026-09-08
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -60,17 +60,28 @@ style conventions are in separate documents:
|
|||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
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
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
outputs the project's name. Scripts that need the name call
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
`script/bootstrap`'s own install step does. 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 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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -89,87 +100,140 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
Dockerfiles install development prerequisites by running `script/bootstrap`
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
rather than duplicating installs inline; COPY `script/` and the dependency
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
running it.
|
||||||
layer stays cached until dependencies change.
|
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
and nothing else:
|
||||||
`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.
|
|
||||||
|
|
||||||
The standard pattern for a Go repo Dockerfile is:
|
```sh
|
||||||
|
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||||
|
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Every `docker build` in `script/` is tagged**, here and in
|
||||||
|
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||||
|
image behind on every invocation, on every developer host and every CI
|
||||||
|
runner; a tagged one replaces the previous image.
|
||||||
|
|
||||||
|
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 cache, its own `TMPDIR` and
|
||||||
|
a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is built with `--no-cache`.** 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 `--no-cache`:
|
||||||
|
`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. 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.
|
||||||
|
|
||||||
|
- **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 Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make fmt-check
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
# Test phase
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -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.
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS builder
|
FROM golang@sha256:... AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
WORKDIR /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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make test
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /app ./cmd/app/
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage, and the last one
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
includes both Go and the linter), so there is no need to install the
|
both Go and the linter), so nothing needs installing.
|
||||||
linter separately.
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
lines a red gate would not fail the build.
|
||||||
failure might not fail the overall 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.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
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.
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
`vips-dev`), install them in the lint phase with `apk add`.
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by
|
||||||
build stage, not the lint stage, because they may require compiled
|
`script/docker` and `script/cibuild`; no stage may call `git describe`.
|
||||||
artifacts or heavier dependencies.
|
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||||
pass.
|
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 is uncached 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.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -189,14 +253,21 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||||
Makefile.
|
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` should use the conditional verbose rerun pattern.** Run tests
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
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:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -209,11 +280,24 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 30s -race -cover ./... || \
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 30s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so the target cannot report a pass it did not earn, and the rerun
|
||||||
|
reproduces a failure instead of replaying it. It leaves the build cache
|
||||||
|
alone, so it costs the runtime of the suite and no recompilation.
|
||||||
|
|
||||||
|
Note that this is a second, independent cache, stacked below the Docker
|
||||||
|
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
|
||||||
|
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
|
||||||
|
it does not guarantee `go test` inside that step does any work, because the
|
||||||
|
`GOCACHE` baked into earlier image layers survives into the re-executed
|
||||||
|
step. They are two separate defects requiring two separate fixes, and a fix
|
||||||
|
for one must not be recorded as covering the other.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -239,10 +323,83 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
a new repo.
|
setting up a new repo. 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: `.git`, 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.
|
||||||
|
|
||||||
|
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
|
||||||
|
it fails quietly there.** In a build stage there is no repository, so
|
||||||
|
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
|
||||||
|
the binary reports no version at all, and the build still exits 0. Compute the
|
||||||
|
version on the host and thread it in as a build arg. `script/docker` and
|
||||||
|
`script/cibuild` do this, byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 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"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$(script/projectname)" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--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=dev` 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 a repo that embeds a
|
||||||
|
tag-derived version must set `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.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -258,9 +415,45 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`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.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
|
||||||
|
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
|
||||||
|
only pin, since no repo installs golangci-lint on the host: bumping the
|
||||||
|
version means changing it and nothing else.
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -379,7 +572,9 @@ style conventions are in separate documents:
|
|||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
subdirectory names:
|
subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints
|
- `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/`
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
+2
-61
@@ -1,69 +1,10 @@
|
|||||||
// Package main is the entry point for the pixad image proxy server.
|
// Package main is the entry point for the pixad image proxy server.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "sneak.berlin/go/pixa/internal/app"
|
||||||
"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 Version string //nolint:gochecknoglobals // set by ldflags
|
||||||
|
|
||||||
var configPath string //nolint:gochecknoglobals // cobra flag
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
rootCmd := &cobra.Command{
|
app.Run(Version)
|
||||||
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,9 +34,10 @@ maintenance_mode: false
|
|||||||
state_dir: ./data
|
state_dir: ./data
|
||||||
|
|
||||||
# SQLite database URL (default:
|
# SQLite database URL (default:
|
||||||
# file:<state_dir>/state.sqlite3?_journal_mode=WAL). An empty value aborts
|
# file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds
|
||||||
# startup; leave the key out to use the default.
|
# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the
|
||||||
# db_url: "file:./data/state.sqlite3?_journal_mode=WAL"
|
# key out to use the default.
|
||||||
|
# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
|
|
||||||
# Image proxy settings
|
# Image proxy settings
|
||||||
# HMAC signing key for URL signatures (required, at least 32 characters)
|
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||||
@@ -45,6 +46,8 @@ signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
|||||||
|
|
||||||
# Hosts that don't require signatures (default: none)
|
# Hosts that don't require signatures (default: none)
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
|
# 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:
|
allowlist_hosts:
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
@@ -52,6 +55,16 @@ allowlist_hosts:
|
|||||||
- github.com
|
- github.com
|
||||||
- user-images.githubusercontent.com
|
- user-images.githubusercontent.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
|
||||||
|
|
||||||
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
||||||
# SSRF protection. These are added to the always-enforced built-in ranges
|
# SSRF protection. These are added to the always-enforced built-in ranges
|
||||||
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
|
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
|
||||||
@@ -102,6 +115,14 @@ upstream_connections: 64
|
|||||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||||
# max_concurrent_processing: 4
|
# max_concurrent_processing: 4
|
||||||
|
|
||||||
|
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
|
||||||
|
# may send per minute; a request over the limit is answered 429 with a
|
||||||
|
# Retry-After header. 0 turns the limit off. Clients are told apart as for
|
||||||
|
# the login limit, so behind a CDN or proxy whose addresses are not in
|
||||||
|
# trusted_proxies 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)
|
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||||
upstream_fetch_timeout: 30s
|
upstream_fetch_timeout: 30s
|
||||||
|
|
||||||
@@ -128,8 +149,9 @@ access_control_allow_origin: "*"
|
|||||||
|
|
||||||
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
||||||
# given; 0 disables the disk cache entirely (every request fetches and
|
# given; 0 disables the disk cache entirely (every request fetches and
|
||||||
# processes uncached). When omitted, the default is 75% of the free
|
# processes uncached). When omitted, the default is 75% of the sum of
|
||||||
# space on the filesystem containing <state_dir>/cache/ at startup,
|
# the free space on the filesystem containing <state_dir>/cache/ and
|
||||||
|
# the bytes of images the cache already holds, worked out at startup,
|
||||||
# with a minimum of 500 MiB.
|
# with a minimum of 500 MiB.
|
||||||
# cache_max_bytes: 10737418240
|
# cache_max_bytes: 10737418240
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -1,26 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
||||||
// explicitly configured cache_max_bytes value is used exactly as
|
// explicitly configured cache_max_bytes value is used exactly as
|
||||||
// given: the 500 MiB floor applies only to the computed default, never
|
// given: the 500 MiB floor applies only to the computed default, never
|
||||||
@@ -151,155 +135,30 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
|
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
||||||
// computed default is 75% of the probed free space when that exceeds
|
// cache_max_bytes is recorded as not explicit, so the cache works out
|
||||||
// the floor.
|
// the default when it opens, and that an explicit zero is recorded as
|
||||||
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
// explicit, so it disables the disk cache instead.
|
||||||
|
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// 4 GiB free -> 3 GiB default.
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
probe := func(string) (uint64, error) { return 4294967296, nil }
|
|
||||||
|
|
||||||
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
omitted, err := configFromYAML(t, signingKeyLine)
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.StateDir = t.TempDir()
|
if omitted.CacheMaxBytesExplicit {
|
||||||
wantCacheDir := filepath.Join(c.StateDir, "cache")
|
t.Error("omitted cache_max_bytes recorded as explicit")
|
||||||
|
|
||||||
var probedPath string
|
|
||||||
|
|
||||||
// 4 GiB free -> 3 GiB default.
|
|
||||||
probe := func(path string) (uint64, error) {
|
|
||||||
probedPath = path
|
|
||||||
|
|
||||||
return 4294967296, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
zero, err := configFromYAML(t, signingKeyLine+"cache_max_bytes: 0\n")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CacheMaxBytes != 3221225472 {
|
if !zero.CacheMaxBytesExplicit {
|
||||||
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472",
|
t.Error("cache_max_bytes: 0 not recorded as explicit")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+156
-64
@@ -4,16 +4,19 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -31,6 +34,7 @@ const (
|
|||||||
DefaultUpstreamFetchTimeout = 30 * time.Second
|
DefaultUpstreamFetchTimeout = 30 * time.Second
|
||||||
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
||||||
DefaultDownstreamTimeout = 60 * time.Second
|
DefaultDownstreamTimeout = 60 * time.Second
|
||||||
|
DefaultImageRequestsPerMinute = 600
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
@@ -46,6 +50,7 @@ const (
|
|||||||
keyMetricsPassword = "metrics.password"
|
keyMetricsPassword = "metrics.password"
|
||||||
keySigningKey = "signing_key"
|
keySigningKey = "signing_key"
|
||||||
keyAllowlistHosts = "allowlist_hosts"
|
keyAllowlistHosts = "allowlist_hosts"
|
||||||
|
keyRefererBlocklist = "referer_blocklist"
|
||||||
keyAllowHTTP = "allow_http"
|
keyAllowHTTP = "allow_http"
|
||||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
keyUpstreamConnections = "upstream_connections"
|
keyUpstreamConnections = "upstream_connections"
|
||||||
@@ -57,10 +62,11 @@ const (
|
|||||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||||
keyDownstreamTimeout = "downstream_timeout"
|
keyDownstreamTimeout = "downstream_timeout"
|
||||||
|
keyImageRequestsPerMinute = "image_requests_per_minute"
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
// config.example.yml. It is 45 characters, so it passes the length
|
// configs/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
|
// check, but it is public in this repository and must be rejected at
|
||||||
// startup so no deployment ever signs URLs with it.
|
// startup so no deployment ever signs URLs with it.
|
||||||
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
@@ -86,20 +92,17 @@ var (
|
|||||||
errMustBeAtLeastOne = errors.New("must be at least 1")
|
errMustBeAtLeastOne = errors.New("must be at least 1")
|
||||||
errValueTooShort = errors.New("value too short")
|
errValueTooShort = errors.New("value too short")
|
||||||
errPlaceholderKey = errors.New(
|
errPlaceholderKey = errors.New(
|
||||||
"is the placeholder from config.example.yml; " +
|
"is the placeholder from configs/config.example.yml; " +
|
||||||
"generate a real key with: openssl rand -base64 32")
|
"generate a real key with: openssl rand -base64 32")
|
||||||
errMustBeSetTogether = errors.New("must be set together")
|
errMustBeSetTogether = errors.New("must be set together")
|
||||||
errMustNotBeNegative = errors.New("must not be negative")
|
errMustNotBeNegative = errors.New("must not be negative")
|
||||||
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
||||||
errNegativeBlockSize = errors.New(
|
|
||||||
"statfs reported negative block size")
|
|
||||||
errValueNull = errors.New(
|
errValueNull = errors.New(
|
||||||
"value is null; omit the key entirely to use the default")
|
"value is null; omit the key entirely to use the default")
|
||||||
errValuesNull = errors.New(
|
errValuesNull = errors.New(
|
||||||
"value is null; omit a key entirely to use its default")
|
"value is null; omit a key entirely to use its default")
|
||||||
errNotBareHostname = errors.New(
|
errNotAHost = errors.New("must be a host name such as " +
|
||||||
"must be a bare hostname without scheme, path, or whitespace")
|
"cdn.example.com or .example.com, or an IP address")
|
||||||
errNoHostnameLabels = errors.New("contains no hostname labels")
|
|
||||||
errNotADuration = errors.New("not a duration such as 30s or 2m")
|
errNotADuration = errors.New("not a duration such as 30s or 2m")
|
||||||
errMustBePositive = errors.New("must be positive")
|
errMustBePositive = errors.New("must be positive")
|
||||||
errNotAnOrigin = errors.New(
|
errNotAnOrigin = errors.New(
|
||||||
@@ -130,6 +133,10 @@ type Config struct {
|
|||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
|
|
||||||
|
// 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
|
// UpstreamConnections is the most concurrent connections to all
|
||||||
// upstream hosts together, on top of the per-host limit.
|
// upstream hosts together, on top of the per-host limit.
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
@@ -153,6 +160,11 @@ type Config struct {
|
|||||||
// of the per-request timeout middleware.
|
// of the per-request timeout middleware.
|
||||||
DownstreamTimeout time.Duration
|
DownstreamTimeout time.Duration
|
||||||
|
|
||||||
|
// ImageRequestsPerMinute is how many requests one client may make to
|
||||||
|
// the image routes per minute; the next is refused with 429 Too Many
|
||||||
|
// Requests. 0 turns the limit off.
|
||||||
|
ImageRequestsPerMinute int
|
||||||
|
|
||||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||||
// fetcher's dialer; the built-in ranges always apply.
|
// fetcher's dialer; the built-in ranges always apply.
|
||||||
@@ -169,18 +181,19 @@ type Config struct {
|
|||||||
// address, and an explicit list replaces the default.
|
// address, and an explicit list replaces the default.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
// CacheMaxBytes is the disk cache size limit in bytes. Only an
|
||||||
// disables the disk cache entirely. When cache_max_bytes is
|
// explicit zero (CacheMaxBytesExplicit true) disables the disk
|
||||||
// omitted from the configuration, this holds the computed default
|
// cache. Zero with CacheMaxBytesExplicit false means
|
||||||
// (75% of free space on the filesystem containing
|
// cache_max_bytes was omitted, and the cache works out the default
|
||||||
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
|
// limit when it opens.
|
||||||
CacheMaxBytes int64
|
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.
|
// explicitly set, in the environment or the configuration file.
|
||||||
// Explicit values are used exactly as given; only an omitted key
|
// Explicit values are used exactly as given; for an omitted key the
|
||||||
// gets the computed default (and its floor) in resolveCacheMaxBytes.
|
// cache works out the default limit when it opens (see
|
||||||
cacheMaxBytesExplicit bool
|
// imgcache.CacheConfig.UseDefaultMaxBytes).
|
||||||
|
CacheMaxBytesExplicit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config instance from the environment and the
|
// New creates a new Config instance from the environment and the
|
||||||
@@ -217,9 +230,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
|
// An omitted cache_max_bytes is worked out and logged when the
|
||||||
if err != nil {
|
// cache opens.
|
||||||
return nil, err
|
if c.CacheMaxBytesExplicit {
|
||||||
|
log.Info("effective cache size limit",
|
||||||
|
"cache_max_bytes", c.CacheMaxBytes,
|
||||||
|
"cache_disabled", c.CacheMaxBytes == 0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Debug {
|
if c.Debug {
|
||||||
@@ -265,7 +282,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loader := &strictLoader{sc: sc}
|
loader := &strictLoader{sc: sc}
|
||||||
|
|
||||||
c := &Config{
|
c := &Config{
|
||||||
Debug: loader.boolVal(keyDebug, false),
|
Debug: loader.boolVal(keyDebug, false),
|
||||||
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
|
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
|
||||||
@@ -293,17 +309,18 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
||||||
DownstreamTimeout: loader.durationVal(
|
DownstreamTimeout: loader.durationVal(
|
||||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
||||||
|
ImageRequestsPerMinute: loader.intVal(
|
||||||
|
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
BlockedNetworks: blockedNetworks,
|
BlockedNetworks: blockedNetworks,
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
|
RefererBlocklist: loader.hostListVal(keyRefererBlocklist),
|
||||||
}
|
}
|
||||||
|
|
||||||
// The computed default for cache_max_bytes needs a validated
|
// The default for an omitted cache_max_bytes is worked out when
|
||||||
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
|
// the cache opens; here we only record whether the operator set
|
||||||
// we only record whether the operator set the key explicitly.
|
// the key explicitly.
|
||||||
if _, present := lookupValue(sc, keyCacheMaxBytes); present {
|
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
|
||||||
c.cacheMaxBytesExplicit = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
// is a default: it applies only when db_url is omitted, never to an
|
// is a default: it applies only when db_url is omitted, never to an
|
||||||
@@ -315,7 +332,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
settingName(keyDBURL), errValueEmpty)
|
settingName(keyDBURL), errValueEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loader.err != nil {
|
if loader.err != nil {
|
||||||
@@ -414,7 +432,8 @@ func isKnownConfigKey(key string) bool {
|
|||||||
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
|
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
|
||||||
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
|
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
||||||
|
keyImageRequestsPerMinute, "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +456,7 @@ func envVarNames() map[string]string {
|
|||||||
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
|
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
|
||||||
keySigningKey: "PIXA_SIGNING_KEY",
|
keySigningKey: "PIXA_SIGNING_KEY",
|
||||||
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
|
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
|
||||||
|
keyRefererBlocklist: "PIXA_REFERER_BLOCKLIST",
|
||||||
keyAllowHTTP: "PIXA_ALLOW_HTTP",
|
keyAllowHTTP: "PIXA_ALLOW_HTTP",
|
||||||
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
|
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
|
||||||
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
|
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
|
||||||
@@ -448,6 +468,7 @@ func envVarNames() map[string]string {
|
|||||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
||||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
||||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
||||||
|
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,8 +569,8 @@ func (c *Config) ensureStateDirWritable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateSigningKey checks that the signing key is present, long
|
// validateSigningKey checks that the signing key is present, long
|
||||||
// enough, and not the public placeholder from config.example.yml. The
|
// enough, and not the public placeholder from configs/config.example.yml.
|
||||||
// key value itself is never echoed in error messages.
|
// The key value itself is never echoed in error messages.
|
||||||
func (c *Config) validateSigningKey() error {
|
func (c *Config) validateSigningKey() error {
|
||||||
if c.SigningKey == "" {
|
if c.SigningKey == "" {
|
||||||
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
|
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
|
||||||
@@ -600,24 +621,29 @@ func (c *Config) validate() error {
|
|||||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero is valid (it turns the limit off); only negative values are
|
||||||
|
// rejected.
|
||||||
|
if c.ImageRequestsPerMinute < 0 {
|
||||||
|
return fmt.Errorf("%s: value %d %w",
|
||||||
|
settingName(keyImageRequestsPerMinute), c.ImageRequestsPerMinute,
|
||||||
|
errMustNotBeNegative)
|
||||||
|
}
|
||||||
|
|
||||||
err = c.validateUpstreamMaxResponseSize()
|
err = c.validateUpstreamMaxResponseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, host := range c.AllowlistHosts {
|
for _, host := range c.AllowlistHosts {
|
||||||
err := validateAllowlistHost(host)
|
err := validateHostPattern(keyAllowlistHosts, host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.SentryDSN != "" {
|
err = c.validateSentryDSN()
|
||||||
parsed, err := url.Parse(c.SentryDSN)
|
if err != nil {
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
return err
|
||||||
return fmt.Errorf("%s: value %q is %w",
|
|
||||||
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||||
@@ -629,6 +655,22 @@ func (c *Config) validate() error {
|
|||||||
return c.validateAccessControlAllowOrigin()
|
return c.validateAccessControlAllowOrigin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme
|
||||||
|
// and a host.
|
||||||
|
func (c *Config) validateSentryDSN() error {
|
||||||
|
if c.SentryDSN == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(c.SentryDSN)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("%s: value %q is %w",
|
||||||
|
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
||||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
||||||
// memory, and the image processor reads one byte past this limit, which
|
// memory, and the image processor reads one byte past this limit, which
|
||||||
@@ -729,27 +771,26 @@ func (c *Config) validateConcurrencyLimits() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
// hostNamePattern matches a host name: letters, digits, hyphens, underscores
|
||||||
// hostname, optionally with a leading dot for suffix matching. URLs,
|
// and dots, optionally after one leading dot.
|
||||||
// paths, and whitespace indicate a misconfigured entry. An entry with
|
var hostNamePattern = regexp.MustCompile(`^\.?[A-Za-z0-9_-][A-Za-z0-9_.-]*$`)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Trim(host, ".") == "" {
|
|
||||||
return fmt.Errorf("%s: entry %q %w",
|
|
||||||
settingName(keyAllowlistHosts), host, errNoHostnameLabels)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s: entry %q %w", settingName(key), host, errNotAHost)
|
||||||
|
}
|
||||||
|
|
||||||
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
||||||
// or standard locations.
|
// or standard locations.
|
||||||
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
|
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
|
||||||
@@ -778,10 +819,19 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
for _, path := range configPaths {
|
for _, path := range configPaths {
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Only a config file that does not exist is skipped, including
|
||||||
|
// 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.
|
||||||
_, statErr := os.Stat(cleanPath)
|
_, statErr := os.Stat(cleanPath)
|
||||||
if statErr == nil {
|
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||||
// A config file that exists but does not parse is a fatal
|
continue
|
||||||
// startup error, never something to skip over.
|
}
|
||||||
|
|
||||||
|
if statErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
@@ -791,7 +841,6 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
|
|
||||||
return sc, nil
|
return sc, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||||
}
|
}
|
||||||
@@ -869,6 +918,19 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
|||||||
return val
|
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
|
// 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
|
// is omitted. A present value that is not a string, or is explicitly
|
||||||
// null, is an error.
|
// null, is an error.
|
||||||
@@ -1166,7 +1228,7 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return nil, errNullConfigValue(key)
|
return nil, errNullConfigValue(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := cidrListEntries(raw, key)
|
entries, err := listEntries(raw, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1186,11 +1248,41 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return prefixes, nil
|
return prefixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cidrListEntries extracts the raw entries of the named CIDR-list key as
|
// parseHostList parses the value of the named config key into host patterns,
|
||||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
// or returns nil if the key is omitted. It accepts a YAML list of strings or a
|
||||||
// comma-separated string; an empty string is an empty list, as for
|
// comma-separated string. An explicitly null value, a wrong type, an empty
|
||||||
// allowlist_hosts. Any other shape is a configuration error.
|
// entry, a non-string entry, or an entry validateHostPattern rejects aborts
|
||||||
func cidrListEntries(raw any, key string) ([]string, error) {
|
// 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) {
|
||||||
switch val := raw.(type) {
|
switch val := raw.(type) {
|
||||||
case []any:
|
case []any:
|
||||||
entries := make([]string, 0, len(val))
|
entries := make([]string, 0, len(val))
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
)
|
)
|
||||||
|
|
||||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
@@ -94,12 +98,43 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
|
|||||||
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
wantDBURL := "file:" + DefaultStateDir +
|
||||||
|
"/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
if c.DBURL != wantDBURL {
|
if c.DBURL != wantDBURL {
|
||||||
t.Errorf("DBURL = %q, want derived default %q", 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) {
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -182,6 +217,25 @@ 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
|
// runAbortCases asserts that each case's config aborts startup with an
|
||||||
// error message mentioning every expected substring.
|
// error message mentioning every expected substring.
|
||||||
func runAbortCases(t *testing.T, cases []abortCase) {
|
func runAbortCases(t *testing.T, cases []abortCase) {
|
||||||
@@ -284,6 +338,20 @@ func invalidHostAndCredentialCases() []abortCase {
|
|||||||
keyAllowlistHosts, "example.com/images",
|
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",
|
name: "allowlist host with whitespace",
|
||||||
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||||
@@ -564,6 +632,116 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
|||||||
t.Logf("got expected error: %v", err)
|
t.Logf("got expected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup checks that a
|
||||||
|
// config file pixa cannot read because it may not enter its directory
|
||||||
|
// aborts startup instead of being passed over.
|
||||||
|
func TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root may enter any directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
configDir := filepath.Join(home, ".config", "pixa-test-nonexistent-app")
|
||||||
|
configPath := filepath.Join(configDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.MkdirAll(configDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create config directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(configPath, []byte(signingKeyLine), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Chmod(configDir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to remove the config directory's permissions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the directory back its permissions so t.TempDir can remove it.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
//nolint:gosec // G302: a directory needs its execute bit to be removed
|
||||||
|
_ = os.Chmod(configDir, 0o700)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ~/.config candidate is the only one that exists: the appname
|
||||||
|
// rules out /etc, and the working directory is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||||
|
sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), configPath) {
|
||||||
|
t.Errorf("error %q does not name the config file %s", err.Error(), configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigFileLinkingToItselfAbortsStartup checks that a config file
|
||||||
|
// pixa cannot read for a reason other than not existing aborts startup,
|
||||||
|
// as root too: a symbolic link to itself fails with "too many levels of
|
||||||
|
// symbolic links".
|
||||||
|
func TestConfigFileLinkingToItselfAbortsStartup(t *testing.T) {
|
||||||
|
workDir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.Symlink("config.yml", filepath.Join(workDir, "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create symbolic link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the working directory's config.yml is there: the appname rules
|
||||||
|
// out /etc, and HOME is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Chdir(workDir)
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||||
|
sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "config.yml") {
|
||||||
|
t.Errorf("error %q does not name the config file config.yml", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigPathThroughFileIsPassedOver checks that a config file path
|
||||||
|
// that runs through a file, such as one under a HOME of /dev/null, is
|
||||||
|
// passed over like one that does not exist, since no file can be there.
|
||||||
|
func TestConfigPathThroughFileIsPassedOver(t *testing.T) {
|
||||||
|
// No config file is there: the appname rules out /etc, HOME is
|
||||||
|
// /dev/null, and the working directory is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", os.DevNull)
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a config path through a file must be passed over, got error: %v",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc != nil {
|
||||||
|
t.Errorf("expected no config file, got config: %v", sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,12 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
|
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
|
||||||
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
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_ALLOW_HTTP", "true")
|
||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||||
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
||||||
|
t.Setenv("PIXA_IMAGE_REQUESTS_PER_MINUTE", "30")
|
||||||
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||||
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||||
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||||
@@ -93,12 +95,14 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
MetricsPassword: "metricspass",
|
MetricsPassword: "metricspass",
|
||||||
SigningKey: validTestSigningKey,
|
SigningKey: validTestSigningKey,
|
||||||
AllowlistHosts: []string{testHostS3, ".example.com"},
|
AllowlistHosts: []string{testHostS3, ".example.com"},
|
||||||
|
RefererBlocklist: []string{"hotlinker.example", ".leech.example"},
|
||||||
AllowHTTP: true,
|
AllowHTTP: true,
|
||||||
UpstreamConnectionsPerHost: 5,
|
UpstreamConnectionsPerHost: 5,
|
||||||
UpstreamConnections: 10,
|
UpstreamConnections: 10,
|
||||||
MaxConcurrentProcessing: 3,
|
MaxConcurrentProcessing: 3,
|
||||||
|
ImageRequestsPerMinute: 30,
|
||||||
CacheMaxBytes: 1024,
|
CacheMaxBytes: 1024,
|
||||||
cacheMaxBytesExplicit: true,
|
CacheMaxBytesExplicit: true,
|
||||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
AccessControlAllowOrigin: "https://app.example.com",
|
AccessControlAllowOrigin: "https://app.example.com",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -237,13 +237,24 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying sql.DB.
|
// 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.
|
||||||
func (s *Database) DB() *sql.DB {
|
func (s *Database) DB() *sql.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) connect(ctx context.Context) error {
|
func (s *Database) connect(ctx context.Context) error {
|
||||||
dbURL := s.config.DBURL
|
// 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)"
|
||||||
|
|
||||||
s.log.Info("connecting to database", "url", dbURL)
|
s.log.Info("connecting to database", "url", dbURL)
|
||||||
|
|
||||||
@@ -254,6 +265,11 @@ func (s *Database) connect(ctx context.Context) error {
|
|||||||
return err
|
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)
|
err = db.PingContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to ping database", "error", err)
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
|
|
||||||
-- Source content blobs
|
-- Source content blobs
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
content_hash TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_accessed_at DATETIME
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
ON source_content(last_accessed_at);
|
ON source_content(last_accessed_at);
|
||||||
@@ -42,9 +40,8 @@ CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(conte
|
|||||||
-- Processed variant blobs
|
-- Processed variant blobs
|
||||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
||||||
-- sidecar with the content type). Tracked here (like source content
|
-- sidecar with the content type). Tracked here (like source content
|
||||||
-- blobs above) so total cache usage can be computed with a SUM query,
|
-- blobs above) so total cache usage is known without a directory scan,
|
||||||
-- never a directory scan, and so LRU eviction has a timestamp to order
|
-- and so LRU eviction has a timestamp to order on.
|
||||||
-- on.
|
|
||||||
CREATE TABLE IF NOT EXISTS variant_content (
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
cache_key TEXT PRIMARY KEY,
|
cache_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
@@ -55,6 +52,74 @@ CREATE TABLE IF NOT EXISTS variant_content (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
ON variant_content(last_accessed_at);
|
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
|
-- Output/transformed content blobs
|
||||||
-- Not written: transformed images are stored in cache/variants and
|
-- Not written: transformed images are stored in cache/variants and
|
||||||
-- tracked in variant_content above.
|
-- tracked in variant_content above.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// Default values for optional fields.
|
// Default values for optional fields.
|
||||||
const (
|
const (
|
||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imgcache.FormatOriginal
|
DefaultFormat = imgcache.FormatJXL
|
||||||
DefaultFitMode = imgcache.FitCover
|
DefaultFitMode = imgcache.FitCover
|
||||||
|
|
||||||
// HKDF salt for URL encryption key derivation
|
// HKDF salt for URL encryption key derivation
|
||||||
@@ -37,7 +37,7 @@ type Payload struct {
|
|||||||
SourceQuery string `cbor:"q,omitempty"` // optional
|
SourceQuery string `cbor:"q,omitempty"` // optional
|
||||||
Width int `cbor:"w,omitempty"` // 0 = original
|
Width int `cbor:"w,omitempty"` // 0 = original
|
||||||
Height int `cbor:"ht,omitempty"` // 0 = original
|
Height int `cbor:"ht,omitempty"` // 0 = original
|
||||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
|
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
|
||||||
Quality int `cbor:"ql,omitempty"` // default: 85
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
||||||
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
||||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
const appname = "pixad"
|
const appname = "pixad"
|
||||||
|
|
||||||
// Version is populated from main() via ldflags.
|
// Version is set by app.Run to the version main was built with.
|
||||||
var Version string //nolint:gochecknoglobals // set from main
|
var Version string //nolint:gochecknoglobals // set by app.Run
|
||||||
|
|
||||||
// Globals holds application-wide constants.
|
// Globals holds application-wide constants.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
|
|||||||
@@ -369,10 +369,15 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri
|
|||||||
scheme = "http"
|
scheme = "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine file extension for the trailing filename
|
// Determine file extension for the trailing filename. A form with no
|
||||||
|
// format makes a token with none, which is served as encurl.DefaultFormat.
|
||||||
ext := format
|
ext := format
|
||||||
if ext == "" || ext == "orig" {
|
|
||||||
ext = "jpg" // Default extension
|
switch format {
|
||||||
|
case "":
|
||||||
|
ext = string(encurl.DefaultFormat)
|
||||||
|
case "orig", "auto":
|
||||||
|
ext = "jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
@@ -27,6 +29,11 @@ type Params struct {
|
|||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
Config *config.Config
|
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.
|
// Handlers provides HTTP request handlers.
|
||||||
@@ -35,11 +42,16 @@ type Handlers struct {
|
|||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
db *database.Database
|
db *database.Database
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
fetcher httpfetcher.Fetcher
|
||||||
imgSvc *imgcache.Service
|
imgSvc *imgcache.Service
|
||||||
imgCache *imgcache.Cache
|
imgCache *imgcache.Cache
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
csrfProtect func(http.Handler) http.Handler
|
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.
|
// New creates a new Handlers instance.
|
||||||
@@ -54,20 +66,27 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
fetcher: params.Fetcher,
|
||||||
csrfProtect: csrfProtect,
|
csrfProtect: csrfProtect,
|
||||||
|
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
//nolint:contextcheck // the eviction loop outlives OnStart; OnStop cancels it
|
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return s.initImageService()
|
return s.initImageService()
|
||||||
},
|
},
|
||||||
|
// The pending counts are written here, before the database's stop
|
||||||
|
// hook closes it.
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
if s.imgCache == nil {
|
if s.imgCache == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.imgCache.StopEviction(ctx)
|
return errors.Join(
|
||||||
|
s.imgCache.StopEviction(ctx),
|
||||||
|
s.imgCache.StopPendingCountWrites(ctx),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,18 +99,24 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|||||||
return s.imgSvc.WaitForProcessing(ctx)
|
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.
|
// initImageService initializes the image cache and service.
|
||||||
func (s *Handlers) initImageService() error {
|
func (s *Handlers) initImageService() error {
|
||||||
// Create the cache. cache_max_bytes: 0 disables the disk cache
|
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
|
||||||
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -102,6 +127,9 @@ func (s *Handlers) initImageService() error {
|
|||||||
// write-pressure passes. No-op when the disk cache is disabled.
|
// write-pressure passes. No-op when the disk cache is disabled.
|
||||||
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
||||||
|
|
||||||
|
// Writes the counts requests could not write by their deadline
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := httpfetcher.DefaultConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
@@ -115,10 +143,12 @@ func (s *Handlers) initImageService() error {
|
|||||||
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
||||||
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||||
|
|
||||||
// Create the service
|
// Create the service. With no fetcher provided, it builds its own from
|
||||||
|
// fetcherCfg.
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
|
Fetcher: s.fetcher,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Allowlist: s.config.AllowlistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleImage handles the main image proxy route:
|
// HandleImage handles the main image proxy route:
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
|
||||||
|
// /v1/image/<host>/<path>/<width>x<height>
|
||||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
req, ok := s.parseImageRequest(w, r)
|
req, ok := s.parseImageRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -39,6 +44,11 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The signature covers the format auto, not the format chosen
|
||||||
|
if !s.chooseAutoFormat(w, r, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get cache key for logging
|
// Get cache key for logging
|
||||||
cacheKey := imgcache.CacheKey(req)
|
cacheKey := imgcache.CacheKey(req)
|
||||||
|
|
||||||
@@ -196,6 +206,16 @@ func parseExpires(query url.Values) (time.Time, error) {
|
|||||||
return time.Unix(exp, 0), nil
|
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.
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
func (s *Handlers) respondImageError(
|
func (s *Handlers) respondImageError(
|
||||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
@@ -227,6 +247,22 @@ func (s *Handlers) respondImageError(
|
|||||||
return
|
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
|
||||||
|
}
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +284,23 @@ func cacheControl(expires time.Time) string {
|
|||||||
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
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
|
// 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
|
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
|
||||||
// answered. An empty etag sets no header and never answers.
|
// answered. An empty etag sets no header and never answers.
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func newSignedHostServer(
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ import (
|
|||||||
// browsers identify the content type.
|
// browsers identify the content type.
|
||||||
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
req, ok := s.parseImageEncRequest(w, r)
|
req, ok := s.parseImageEncRequest(w, r)
|
||||||
if !ok {
|
if !ok || !s.chooseAutoFormat(w, r, req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +161,14 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
|||||||
errors.Is(err, imageprocessor.ErrTooManyImages):
|
errors.Is(err, imageprocessor.ErrTooManyImages):
|
||||||
s.respondError(w, "server busy, try again later",
|
s.respondError(w, "server busy, try again later",
|
||||||
http.StatusServiceUnavailable)
|
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)
|
||||||
default:
|
default:
|
||||||
s.log.Error("image request failed", "error", err)
|
s.log.Error("image request failed", "error", err)
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ const (
|
|||||||
contentTypeGIF = "image/gif"
|
contentTypeGIF = "image/gif"
|
||||||
contentTypeWebP = "image/webp"
|
contentTypeWebP = "image/webp"
|
||||||
contentTypeAVIF = "image/avif"
|
contentTypeAVIF = "image/avif"
|
||||||
|
contentTypeJXL = "image/jxl"
|
||||||
contentTypeSVG = "image/svg+xml"
|
contentTypeSVG = "image/svg+xml"
|
||||||
contentTypeOctetStream = "application/octet-stream"
|
contentTypeOctetStream = "application/octet-stream"
|
||||||
)
|
)
|
||||||
@@ -137,6 +138,11 @@ type Config struct {
|
|||||||
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||||
// dialer, in addition to the always-enforced built-in ranges.
|
// dialer, in addition to the always-enforced built-in ranges.
|
||||||
BlockedNetworks []netip.Prefix
|
BlockedNetworks []netip.Prefix
|
||||||
|
// 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.
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
@@ -151,7 +157,7 @@ func DefaultConfig() *Config {
|
|||||||
contentTypeGIF,
|
contentTypeGIF,
|
||||||
contentTypeWebP,
|
contentTypeWebP,
|
||||||
contentTypeAVIF,
|
contentTypeAVIF,
|
||||||
contentTypeSVG,
|
contentTypeJXL,
|
||||||
},
|
},
|
||||||
AllowHTTP: false,
|
AllowHTTP: false,
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
@@ -190,13 +196,19 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
config = DefaultConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
// Unless config.DialContext replaces it, the transport connects with
|
||||||
// re-checks at connect time (closing the DNS-rebinding window) against
|
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
||||||
// both the built-in ranges and the operator-supplied blocklist.
|
// (closing the DNS-rebinding window) against both the built-in ranges
|
||||||
transport := &http.Transport{
|
// and the operator-supplied blocklist.
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
dialContext := config.DialContext
|
||||||
|
if dialContext == nil {
|
||||||
|
dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialContext,
|
||||||
TLSHandshakeTimeout: DefaultTLSTimeout,
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||||
MaxIdleConns: DefaultMaxIdleConns,
|
MaxIdleConns: DefaultMaxIdleConns,
|
||||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,8 @@ func detectContentTypeFromPath(path string) string {
|
|||||||
return contentTypeWebP
|
return contentTypeWebP
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return contentTypeAVIF
|
return contentTypeAVIF
|
||||||
|
case strings.HasSuffix(path, ".jxl"):
|
||||||
|
return contentTypeJXL
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return contentTypeSVG
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processedSize runs input through Process and returns the output's size in
|
||||||
|
// bytes.
|
||||||
|
func processedSize(t *testing.T, input []byte, req *Request) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
|
||||||
|
return result.ContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodePNG encodes img as PNG with Go's encoder.
|
||||||
|
func encodePNG(t *testing.T, img image.Image) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode decodes input with vips, as Process does.
|
||||||
|
func decode(t *testing.T, input []byte) *vips.ImageRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img, err := vips.NewImageFromBuffer(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(img.Close)
|
||||||
|
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_PNGIsCompressed verifies that a PNG output is
|
||||||
|
// compressed: a flat 200x150 image, 90,000 bytes of raw pixels, comes out at
|
||||||
|
// a small fraction of that.
|
||||||
|
func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 200, 150
|
||||||
|
|
||||||
|
flat := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
flat.Set(x, y, color.RGBA{R: 40, G: 120, B: 200, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
|
||||||
|
|
||||||
|
const rawBytes = width * height * 3
|
||||||
|
if size > rawBytes/10 {
|
||||||
|
t.Errorf("PNG output is %d bytes, want under a tenth of its %d raw",
|
||||||
|
size, rawBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_WebPAtDefaultEffort verifies that WebP is saved at
|
||||||
|
// libvips' default effort, 4: the output is the size of the same image saved
|
||||||
|
// at that effort.
|
||||||
|
func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
ReductionEffort: 4,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportWebp() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("WebP output is %d bytes, want %d, the size at effort 4",
|
||||||
|
size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_AVIFAtEffort1 verifies that AVIF is saved at effort 1
|
||||||
|
// with 8 bits per sample: the output is the size of the same image saved
|
||||||
|
// with those settings. The source is 16-bit, which libvips saves with 12
|
||||||
|
// bits when it is not given a bit depth, and 640x480: on a small image,
|
||||||
|
// such as 64x48, efforts 1 and 2 give the same output.
|
||||||
|
func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 640, 480
|
||||||
|
|
||||||
|
source := image.NewRGBA64(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
source.Set(x, y, color.RGBA64{
|
||||||
|
R: uint16((x * 65535 / width) & 0xffff),
|
||||||
|
G: uint16((y * 65535 / height) & 0xffff),
|
||||||
|
B: 32768,
|
||||||
|
A: 65535,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
Effort: 1,
|
||||||
|
Bitdepth: 8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportAvif() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("AVIF output is %d bytes, want %d, the size at effort 1 "+
|
||||||
|
"and 8 bits", size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,24 @@ 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.
|
// Format represents supported output image formats.
|
||||||
type Format string
|
type Format string
|
||||||
|
|
||||||
@@ -47,6 +65,7 @@ const (
|
|||||||
FormatPNG Format = "png"
|
FormatPNG Format = "png"
|
||||||
FormatWebP Format = "webp"
|
FormatWebP Format = "webp"
|
||||||
FormatAVIF Format = "avif"
|
FormatAVIF Format = "avif"
|
||||||
|
FormatJXL Format = "jxl"
|
||||||
FormatGIF Format = "gif"
|
FormatGIF Format = "gif"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,10 +133,26 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
|||||||
// configured byte limit.
|
// configured byte limit.
|
||||||
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
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")
|
||||||
|
|
||||||
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
||||||
// not supported.
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
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
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
// processed and none finishes within ProcessingWaitTimeout.
|
// processed and none finishes within ProcessingWaitTimeout.
|
||||||
var ErrTooManyImages = errors.New("too many images being processed at once")
|
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||||
@@ -200,6 +235,17 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, ErrInputDataTooLarge
|
return nil, ErrInputDataTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// Decode image
|
// Decode image
|
||||||
img, err := vips.NewImageFromBuffer(data)
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,16 +264,16 @@ func (p *ImageProcessor) Process(
|
|||||||
origWidth := img.Width()
|
origWidth := img.Width()
|
||||||
origHeight := img.Height()
|
origHeight := img.Height()
|
||||||
|
|
||||||
// Detect input format
|
|
||||||
inputFormat := p.detectFormat(img)
|
|
||||||
|
|
||||||
// Validate input dimensions to prevent DoS via decompression bombs
|
// Validate input dimensions to prevent DoS via decompression bombs
|
||||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||||
return nil, ErrInputTooLarge
|
return nil, ErrInputTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine target dimensions
|
// Determine target dimensions; too large ones are refused before resizing
|
||||||
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Resize if needed
|
// Resize if needed
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
@@ -237,10 +283,10 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine output format
|
// orig is the source's own format; encode refuses an empty format
|
||||||
outputFormat := req.Format
|
outputFormat := req.Format
|
||||||
if outputFormat == FormatOriginal || outputFormat == "" {
|
if outputFormat == FormatOriginal {
|
||||||
outputFormat = p.formatFromString(inputFormat)
|
outputFormat = sourceFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode to target format
|
// Encode to target format
|
||||||
@@ -263,21 +309,29 @@ func (p *ImageProcessor) Process(
|
|||||||
|
|
||||||
// targetDimensions calculates the output dimensions for a requested size,
|
// targetDimensions calculates the output dimensions for a requested size,
|
||||||
// scaling proportionally when only one dimension is given and keeping the
|
// scaling proportionally when only one dimension is given and keeping the
|
||||||
// original dimensions when both are zero.
|
// original dimensions when both are zero. A side scaled proportionally can
|
||||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
// come out above MaxOutputDimension, which is ErrOutputTooLarge.
|
||||||
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
||||||
|
width, height := size.Width, size.Height
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case size.Width == 0 && size.Height == 0:
|
case width == 0 && height == 0:
|
||||||
// Both are 0: keep original size
|
// Both are 0: keep original size
|
||||||
return origWidth, origHeight
|
return origWidth, origHeight, nil
|
||||||
case size.Width == 0:
|
case width == 0:
|
||||||
// Only height specified: calculate width proportionally
|
// Only height specified: calculate width proportionally
|
||||||
return origWidth * size.Height / origHeight, size.Height
|
width = origWidth * height / origHeight
|
||||||
case size.Height == 0:
|
case height == 0:
|
||||||
// Only width specified: calculate height proportionally
|
// Only width specified: calculate height proportionally
|
||||||
return size.Width, origHeight * size.Width / origWidth
|
height = origHeight * 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.
|
// MIME types for the supported image formats.
|
||||||
@@ -287,6 +341,7 @@ const (
|
|||||||
mimeGIF = "image/gif"
|
mimeGIF = "image/gif"
|
||||||
mimeWebP = "image/webp"
|
mimeWebP = "image/webp"
|
||||||
mimeAVIF = "image/avif"
|
mimeAVIF = "image/avif"
|
||||||
|
mimeJXL = "image/jxl"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read.
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
@@ -297,6 +352,7 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
|
|||||||
mimeGIF,
|
mimeGIF,
|
||||||
mimeWebP,
|
mimeWebP,
|
||||||
mimeAVIF,
|
mimeAVIF,
|
||||||
|
mimeJXL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +364,7 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
|||||||
FormatGIF,
|
FormatGIF,
|
||||||
FormatWebP,
|
FormatWebP,
|
||||||
FormatAVIF,
|
FormatAVIF,
|
||||||
|
FormatJXL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +381,8 @@ func FormatToMIME(format Format) string {
|
|||||||
return mimeGIF
|
return mimeGIF
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return mimeAVIF
|
return mimeAVIF
|
||||||
|
case FormatJXL:
|
||||||
|
return mimeJXL
|
||||||
case FormatOriginal:
|
case FormatOriginal:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
default:
|
default:
|
||||||
@@ -379,10 +438,9 @@ func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// detectFormat returns the format string for a govips image type.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||||
format := img.Format()
|
switch imageType {
|
||||||
switch format {
|
|
||||||
case vips.ImageTypeJPEG:
|
case vips.ImageTypeJPEG:
|
||||||
return "jpeg"
|
return "jpeg"
|
||||||
case vips.ImageTypePNG:
|
case vips.ImageTypePNG:
|
||||||
@@ -393,16 +451,20 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
|||||||
return "webp"
|
return "webp"
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
return string(FormatAVIF)
|
return string(FormatAVIF)
|
||||||
|
case vips.ImageTypeJXL:
|
||||||
|
return string(FormatJXL)
|
||||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
vips.ImageTypeJP2K, vips.ImageTypeJXL:
|
vips.ImageTypeJP2K:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize resizes the image according to the fit mode.
|
// resize resizes the image according to the fit mode. With FitOutside one
|
||||||
|
// side can come out larger than requested; when it would be above
|
||||||
|
// MaxOutputDimension, resize returns ErrOutputTooLarge without resizing.
|
||||||
func (p *ImageProcessor) resize(
|
func (p *ImageProcessor) resize(
|
||||||
img *vips.ImageRef, width, height int, fit FitMode,
|
img *vips.ImageRef, width, height int, fit FitMode,
|
||||||
) error {
|
) error {
|
||||||
@@ -450,6 +512,12 @@ func (p *ImageProcessor) resize(
|
|||||||
newW := int(float64(imgW) * scale)
|
newW := int(float64(imgW) * scale)
|
||||||
newH := int(float64(imgH) * scale)
|
newH := int(float64(imgH) * scale)
|
||||||
|
|
||||||
|
// The dimension that exceeds the target is held to the maximum too
|
||||||
|
if newW > MaxOutputDimension || newH > MaxOutputDimension {
|
||||||
|
return fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
|
||||||
|
newW, newH, MaxOutputDimension)
|
||||||
|
}
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -467,44 +535,6 @@ func (p *ImageProcessor) encode(
|
|||||||
quality = defaultQuality
|
quality = defaultQuality
|
||||||
}
|
}
|
||||||
|
|
||||||
var params vips.ExportParams
|
|
||||||
|
|
||||||
switch format {
|
|
||||||
case FormatJPEG:
|
|
||||||
params = vips.ExportParams{
|
|
||||||
Format: vips.ImageTypeJPEG,
|
|
||||||
Quality: quality,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatPNG:
|
|
||||||
params = vips.ExportParams{
|
|
||||||
Format: vips.ImageTypePNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatGIF:
|
|
||||||
params = vips.ExportParams{
|
|
||||||
Format: vips.ImageTypeGIF,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatWebP:
|
|
||||||
params = vips.ExportParams{
|
|
||||||
Format: vips.ImageTypeWEBP,
|
|
||||||
Quality: quality,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatAVIF:
|
|
||||||
params = vips.ExportParams{
|
|
||||||
Format: vips.ImageTypeAVIF,
|
|
||||||
Quality: quality,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatOriginal:
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stripping drops the ICC profile as well, and clients show an image
|
// Stripping drops the ICC profile as well, and clients show an image
|
||||||
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
||||||
// libvips' built-in profile; govips' own sRGB path variable is set on
|
// libvips' built-in profile; govips' own sRGB path variable is set on
|
||||||
@@ -516,11 +546,166 @@ func (p *ImageProcessor) encode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
switch format {
|
||||||
// GIF, which carries none of them.
|
case FormatJPEG:
|
||||||
params.StripMetadata = true
|
return exportJPEG(img, quality)
|
||||||
|
|
||||||
output, _, err := img.Export(¶ms)
|
case FormatPNG:
|
||||||
|
return exportPNG(img)
|
||||||
|
|
||||||
|
case FormatGIF:
|
||||||
|
return exportGIF(img)
|
||||||
|
|
||||||
|
case FormatWebP:
|
||||||
|
return exportWebP(img, quality)
|
||||||
|
|
||||||
|
case FormatAVIF:
|
||||||
|
return exportAVIF(img, quality)
|
||||||
|
|
||||||
|
case FormatJXL:
|
||||||
|
return exportJXL(img, quality)
|
||||||
|
|
||||||
|
case FormatOriginal:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
|
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 libvips' default WebP effort, from 0 (fastest) to 6.
|
||||||
|
const webpEffort = 4
|
||||||
|
|
||||||
|
// exportWebP encodes img as lossy WebP at quality and libvips' default
|
||||||
|
// effort, without metadata.
|
||||||
|
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportWebp(&vips.WebpExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: quality,
|
||||||
|
ReductionEffort: webpEffort,
|
||||||
|
})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// avifEffort is the AVIF effort, from 0 (fastest) to 9; 1 is the lowest
|
||||||
|
// govips can set. With one thread, as pixad runs libvips, 1 takes about 51
|
||||||
|
// seconds to save an 8192x8192 image of random pixels, the worst case,
|
||||||
|
// against the default downstream_timeout of 60 seconds. On an image of
|
||||||
|
// milder noise, which 1 saves in about 12 seconds, 2 takes nearly a minute
|
||||||
|
// and libvips' default, 4, takes minutes.
|
||||||
|
const avifEffort = 1
|
||||||
|
|
||||||
|
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
|
||||||
|
// libvips would save a 16-bit image with 12, but at avifEffort that takes
|
||||||
|
// about 54 seconds for a 16-bit 8192x8192 image of milder noise, nearly all
|
||||||
|
// of the default downstream_timeout, and about 12 seconds with 8.
|
||||||
|
const avifBitdepth = 8
|
||||||
|
|
||||||
|
// exportAVIF encodes img as lossy AVIF at quality, avifEffort and
|
||||||
|
// avifBitdepth, without metadata.
|
||||||
|
func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportAvif(&vips.AvifExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: quality,
|
||||||
|
Effort: avifEffort,
|
||||||
|
Bitdepth: avifBitdepth,
|
||||||
|
})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// jxlResolution is the resolution every JPEG XL image is saved with, in
|
||||||
|
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
|
||||||
|
// JPEG that names none.
|
||||||
|
const jxlResolution = 72 / 25.4
|
||||||
|
|
||||||
|
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
|
||||||
|
// and 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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to convert CMYK to sRGB: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// govips cannot make libvips strip metadata from JPEG XL, so it is
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
output, _, err := toSave.ExportJxl(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -528,20 +713,39 @@ func (p *ImageProcessor) encode(
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatFromString converts a format string to Format.
|
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
|
||||||
func (p *ImageProcessor) formatFromString(format string) Format {
|
// 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) {
|
||||||
switch format {
|
switch format {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return FormatJPEG
|
return FormatJPEG, nil
|
||||||
case "png":
|
case "png":
|
||||||
return FormatPNG
|
return FormatPNG, nil
|
||||||
case "gif":
|
case "gif":
|
||||||
return FormatGIF
|
return FormatGIF, nil
|
||||||
case "webp":
|
case "webp":
|
||||||
return FormatWebP
|
return FormatWebP, nil
|
||||||
case string(FormatAVIF):
|
case string(FormatAVIF):
|
||||||
return FormatAVIF
|
return FormatAVIF, nil
|
||||||
|
case string(FormatJXL):
|
||||||
|
return FormatJXL, nil
|
||||||
default:
|
default:
|
||||||
return FormatJPEG
|
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 352 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 799 B |
+130
-21
@@ -11,6 +11,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
@@ -33,15 +35,19 @@ const metaCacheSize = 10000
|
|||||||
// CacheConfig holds cache configuration.
|
// CacheConfig holds cache configuration.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
StateDir string
|
StateDir string
|
||||||
CacheTTL time.Duration
|
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
|
|
||||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
// enforces. Zero means no limit is enforced (no eviction). The
|
// enforces. Zero means no limit is enforced (no eviction).
|
||||||
// config layer supplies the computed default when the operator
|
|
||||||
// omits cache_max_bytes.
|
|
||||||
MaxBytes int64
|
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
|
// DisableDiskCache turns the disk cache off entirely: no cache
|
||||||
// directories are created, lookups always miss, stores are
|
// directories are created, lookups always miss, stores are
|
||||||
// no-ops, and no eviction machinery runs. The config layer sets
|
// no-ops, and no eviction machinery runs. The config layer sets
|
||||||
@@ -74,6 +80,27 @@ type Cache struct {
|
|||||||
evictionDone chan struct{}
|
evictionDone chan struct{}
|
||||||
evictionCancel context.CancelFunc
|
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
|
// metaCache holds the content types of the variants most recently
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
// 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.
|
// It never stands in for the variant file, which is always opened.
|
||||||
@@ -91,10 +118,29 @@ type Cache struct {
|
|||||||
// deterministically pause inside that window to exercise
|
// deterministically pause inside that window to exercise
|
||||||
// concurrent stores against it; production code leaves it nil.
|
// concurrent stores against it; production code leaves it nil.
|
||||||
evictSourceBlobTestHook func(ContentHash)
|
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.
|
// NewCache creates a new cache instance.
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
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
|
log := config.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
@@ -114,6 +160,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: metaCache,
|
metaCache: metaCache,
|
||||||
contentLocks: newContentLock(),
|
contentLocks: newContentLock(),
|
||||||
|
|
||||||
|
pendingCountsAdded: make(chan struct{}, 1),
|
||||||
|
pendingCountsDone: make(chan struct{}),
|
||||||
|
|
||||||
|
reconciliationPageSize: defaultReconciliationPageSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
@@ -145,6 +196,20 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
c.variants = variants
|
c.variants = variants
|
||||||
c.srcMetadata = srcMetadata
|
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
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,18 +499,14 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
|
|||||||
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||||
var stats CacheStats
|
var stats CacheStats
|
||||||
|
|
||||||
// Fetch hit/miss counts from the stats table
|
err := c.readHitAndMissCounts(ctx, &stats)
|
||||||
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) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count and size the cached source images and processed variants. A
|
// Count and size the cached source images and processed variants from
|
||||||
// disabled cache holds none, whatever rows an earlier run left.
|
// their tables. A disabled cache holds none, whatever rows an earlier
|
||||||
|
// run left.
|
||||||
if !c.disabled {
|
if !c.disabled {
|
||||||
err = c.db.QueryRowContext(ctx, `
|
err = c.db.QueryRowContext(ctx, `
|
||||||
SELECT (SELECT COUNT(*) FROM source_content)
|
SELECT (SELECT COUNT(*) FROM source_content)
|
||||||
@@ -455,7 +516,7 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
c.log.Warn("failed to count cache items for stats", "error", err)
|
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
stats.TotalSizeBytes, err = c.sumContentSizeBytes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("failed to sum cache size for stats", "error", err)
|
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||||
}
|
}
|
||||||
@@ -470,19 +531,30 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||||
// fetchBytes bytes, as IncrementUpstreamFetch does.
|
// 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).
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||||
|
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
pendingCount := &c.pendingMisses
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
pendingCount = &c.pendingHits
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET hit_count = hit_count + 1,
|
SET hit_count = hit_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET miss_count = miss_count + 1,
|
SET miss_count = miss_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -490,7 +562,10 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
c.addPendingCount(pendingCount, 1)
|
||||||
|
case err != nil:
|
||||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,14 +579,22 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
SET upstream_fetch_count = upstream_fetch_count + 1,
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`, fetchBytes)
|
`, fetchBytes)
|
||||||
if err != nil {
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
c.addPendingCount(&c.pendingUpstreamFetches, 1)
|
||||||
|
c.addPendingCount(&c.pendingUpstreamFetchBytes, fetchBytes)
|
||||||
|
case err != nil:
|
||||||
c.log.Warn("failed to count upstream fetch",
|
c.log.Warn("failed to count upstream fetch",
|
||||||
"fetch_bytes", fetchBytes, "error", err)
|
"fetch_bytes", fetchBytes, "error", err)
|
||||||
}
|
}
|
||||||
@@ -519,17 +602,43 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
|||||||
|
|
||||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||||
_, err := c.db.ExecContext(ctx, `
|
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET transform_count = transform_count + 1,
|
SET transform_count = transform_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
c.addPendingCount(&c.pendingTransforms, 1)
|
||||||
|
case err != nil:
|
||||||
c.log.Warn("failed to count transform", "error", err)
|
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
|
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||||
// hold: it reads the content type from the variant's .meta file and
|
// hold: it reads the content type from the variant's .meta file and
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ func setupTestCache(t *testing.T) *Cache {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,7 +240,6 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
// Very short negative TTL for testing
|
// Very short negative TTL for testing
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
NegativeTTL: 1 * time.Millisecond,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
-39
@@ -21,6 +21,12 @@ const DefaultEvictionInterval = 5 * time.Minute
|
|||||||
// and source blobs) one eviction pass fetches from the database.
|
// and source blobs) one eviction pass fetches from the database.
|
||||||
const evictionBatchSize = 100
|
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
|
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
||||||
// crashed write) must be before reconciliation removes it. Fresh temp
|
// crashed write) must be before reconciliation removes it. Fresh temp
|
||||||
// files may still belong to an in-flight store.
|
// files may still belong to an in-flight store.
|
||||||
@@ -45,7 +51,9 @@ const fallbackContentType = "application/octet-stream"
|
|||||||
|
|
||||||
// UsageBytes returns the total number of bytes of cache content
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
// tracked in the database (source content blobs plus processed
|
// tracked in the database (source content blobs plus processed
|
||||||
// variants). It never scans the cache directories.
|
// 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.
|
||||||
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -53,12 +61,11 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx,
|
||||||
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
`SELECT total_size_bytes FROM cache_usage WHERE id = 1`,
|
||||||
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
).Scan(&total)
|
||||||
`).Scan(&total)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
return 0, fmt.Errorf("failed to read cache usage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return total, nil
|
return total, nil
|
||||||
@@ -236,16 +243,19 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
|
|||||||
return candidates, nil
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceCandidates returns the least recently used source blobs. Rows
|
// sourceCandidatesQuery selects the least recently used source blobs. It
|
||||||
// written before the LRU column existed fall back to fetched_at.
|
// orders by the last_accessed_at column itself, not by an expression, so
|
||||||
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
// SQLite reads the rows in order from that column's index instead of
|
||||||
rows, err := c.db.QueryContext(ctx, `
|
// sorting the whole table.
|
||||||
SELECT content_hash, size_bytes,
|
const sourceCandidatesQuery = `
|
||||||
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
SELECT content_hash, size_bytes, last_accessed_at
|
||||||
FROM source_content
|
FROM source_content
|
||||||
ORDER BY lru ASC, content_hash ASC
|
ORDER BY last_accessed_at ASC, content_hash ASC
|
||||||
LIMIT ?
|
LIMIT ?`
|
||||||
`, evictionBatchSize)
|
|
||||||
|
// sourceCandidates returns the least recently used source blobs.
|
||||||
|
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, sourceCandidatesQuery, evictionBatchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
||||||
}
|
}
|
||||||
@@ -532,11 +542,13 @@ func (c *Cache) runReconciliationPass(ctx context.Context) {
|
|||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops accounting rows whose
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
// files are missing, removes source blob files the database does not
|
// files are missing, removes source blob files the database does not
|
||||||
// know (and rows whose files are gone), and sweeps stale temp files
|
// know (and rows whose files are gone), sweeps stale temp files left
|
||||||
// left behind by crashed writes. Running it periodically, not just
|
// behind by crashed writes, and last checks the total cache usage
|
||||||
// once, bounds how long such drift can accumulate unaccounted for on a
|
// against the tables. It reads the tables a page at a time. Running it
|
||||||
// long-running process to one eviction interval. Once ctx is cancelled,
|
// periodically, not just once, bounds how long such drift can
|
||||||
// it stops at the next file or row and returns ctx's error.
|
// 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 {
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil
|
return nil
|
||||||
@@ -562,7 +574,7 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return c.reconcileUsageTotal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileVariantFiles walks the variant storage directory, adopting
|
// reconcileVariantFiles walks the variant storage directory, adopting
|
||||||
@@ -657,11 +669,22 @@ func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
|||||||
// reconcileVariantRows drops accounting rows whose variant files are
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
// missing, so the database never references deleted content.
|
// missing, so the database never references deleted content.
|
||||||
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
keys, err := c.allVariantKeys(ctx)
|
var after VariantKey
|
||||||
|
|
||||||
|
for {
|
||||||
|
keys, err := c.variantKeysAfter(ctx, after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.reconciliationReadTestHook != nil {
|
||||||
|
c.reconciliationReadTestHook(len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -680,23 +703,29 @@ func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
|||||||
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
after = keys[len(keys)-1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allVariantKeys returns every tracked variant cache key.
|
// variantKeysAfter returns, in order, up to c.reconciliationPageSize
|
||||||
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
|
// tracked variant cache keys that sort after the given one.
|
||||||
return queryStringColumn[VariantKey](ctx, c.db,
|
func (c *Cache) variantKeysAfter(
|
||||||
`SELECT cache_key FROM variant_content`, "variant keys", "variant key")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryStringColumn runs a single-column query and returns the column
|
// queryStringColumn runs a single-column query with args and returns the
|
||||||
// values as T. plural names the set for the query and scan failure
|
// column values as T. plural names the set for the query and scan
|
||||||
// messages; singular names one row for the scan and iteration failure
|
// failure messages; singular names one row for the scan and iteration
|
||||||
// messages.
|
// failure messages.
|
||||||
func queryStringColumn[T ~string](
|
func queryStringColumn[T ~string](
|
||||||
ctx context.Context, db *sql.DB, query, plural, singular string,
|
ctx context.Context, db *sql.DB, query, plural, singular string, args ...any,
|
||||||
) ([]T, error) {
|
) ([]T, error) {
|
||||||
rows, err := db.QueryContext(ctx, query)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
||||||
}
|
}
|
||||||
@@ -791,11 +820,22 @@ func (c *Cache) removeUntrackedSourceFile(
|
|||||||
// reconcileSourceRows removes source_content rows (and their metadata
|
// reconcileSourceRows removes source_content rows (and their metadata
|
||||||
// references and sidecars) whose blob files are missing on disk.
|
// references and sidecars) whose blob files are missing on disk.
|
||||||
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
||||||
hashes, err := c.allSourceContentHashes(ctx)
|
var after ContentHash
|
||||||
|
|
||||||
|
for {
|
||||||
|
hashes, err := c.sourceContentHashesAfter(ctx, after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.reconciliationReadTestHook != nil {
|
||||||
|
c.reconciliationReadTestHook(len(hashes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, hash := range hashes {
|
for _, hash := range hashes {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -815,14 +855,148 @@ func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
|||||||
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
after = hashes[len(hashes)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// allSourceContentHashes returns every tracked source content hash.
|
corrected, err := c.correctUsageTotal(ctx, sumBytes, changeCount)
|
||||||
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
if err != nil || !corrected {
|
||||||
return queryStringColumn[ContentHash](ctx, c.db,
|
return err
|
||||||
`SELECT content_hash FROM source_content`,
|
}
|
||||||
"source content hashes", "content hash")
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepStaleTempFile removes a temp file left behind by a crashed
|
// sweepStaleTempFile removes a temp file left behind by a crashed
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
// backwards compatibility with existing fixtures.
|
// backwards compatibility with existing fixtures.
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
MaxBytes: maxBytes,
|
MaxBytes: maxBytes,
|
||||||
DisableDiskCache: maxBytes == 0,
|
DisableDiskCache: maxBytes == 0,
|
||||||
@@ -681,6 +680,11 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvictionRunsOnPeriodicSchedule writes three variant files straight
|
||||||
|
// to disk, bypassing StoreVariant, so they have no accounting rows and no
|
||||||
|
// write-pressure notification fires. Only a periodic reconciliation pass
|
||||||
|
// can then adopt them, and only the eviction pass that follows it can
|
||||||
|
// evict them.
|
||||||
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -688,13 +692,29 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
// Start the evictor while the cache is empty, then create tracked
|
// Hold the test database's only connection, so the startup pass
|
||||||
// over-limit state WITHOUT going through the store methods, so no
|
// waits for it after walking the still empty variant directory: the
|
||||||
// write-pressure notification fires and only the periodic ticker
|
// files written while it waits are first seen by a periodic pass.
|
||||||
// can trigger eviction.
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
cache.StartEviction(100 * time.Millisecond)
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for cache.db.Stats().WaitCount == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the startup pass never waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
@@ -703,25 +723,43 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
_, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store variant file: %v", err)
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = cache.db.ExecContext(t.Context(),
|
_ = conn.Close()
|
||||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
|
||||||
VALUES (?, ?, ?)`,
|
// Only one of the 1000-byte files fits under the limit: wait until
|
||||||
string(key), len(content), "image/webp",
|
// the evictor has removed the other two.
|
||||||
)
|
stored := len(keys)
|
||||||
|
deadline = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for stored > 1 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if cache.variants.Exists(key) {
|
||||||
|
stored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored > 1 {
|
||||||
|
t.Fatalf("periodic schedule did not trigger eviction: %d of %d "+
|
||||||
|
"variant files still on disk, want at most 1", stored, len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
|
||||||
if usage > limit {
|
if usage > limit {
|
||||||
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
usage, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
@@ -808,15 +846,28 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
const interval = 100 * time.Millisecond
|
// Hold the test database's only connection, so the startup pass
|
||||||
|
// waits for it after walking the still empty variant directory: the
|
||||||
|
// file written while it waits is first seen by a periodic pass.
|
||||||
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
cache.StartEviction(interval)
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
// Let startup reconciliation run and settle on an empty cache
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
// before introducing the untracked file, so the adoption we assert
|
|
||||||
// below can only be the work of a later, periodic pass.
|
for cache.db.Stats().WaitCount == 0 {
|
||||||
time.Sleep(3 * interval)
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the startup pass never waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate a variant whose accounting insert failed after the
|
// Simulate a variant whose accounting insert failed after the
|
||||||
// process was already running and serving requests: the content
|
// process was already running and serving requests: the content
|
||||||
@@ -825,14 +876,16 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
// insert had failed and only the file write had succeeded.
|
// insert had failed and only the file write had succeeded.
|
||||||
untracked := bytes.Repeat([]byte{0x41}, 900)
|
untracked := bytes.Repeat([]byte{0x41}, 900)
|
||||||
|
|
||||||
_, err := cache.variants.Store(
|
_, err = cache.variants.Store(
|
||||||
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
_ = conn.Close()
|
||||||
|
|
||||||
|
deadline = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
var usage int64
|
var usage int64
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ const (
|
|||||||
FormatPNG ImageFormat = "png"
|
FormatPNG ImageFormat = "png"
|
||||||
FormatWebP ImageFormat = "webp"
|
FormatWebP ImageFormat = "webp"
|
||||||
FormatAVIF ImageFormat = "avif"
|
FormatAVIF ImageFormat = "avif"
|
||||||
|
FormatJXL ImageFormat = "jxl"
|
||||||
FormatGIF ImageFormat = "gif"
|
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
|
// Size represents requested image dimensions
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ import "time"
|
|||||||
|
|
||||||
// CacheConfig defaults.
|
// CacheConfig defaults.
|
||||||
const (
|
const (
|
||||||
DefaultCacheTTL = 24 * time.Hour
|
|
||||||
DefaultNegativeTTL = 5 * time.Minute
|
DefaultNegativeTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +62,6 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ type Service struct {
|
|||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
// Cache is the cache instance
|
// Cache is the cache instance
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
// FetcherConfig configures the upstream fetcher built when Fetcher is
|
||||||
|
// not set. Its AllowHTTP and MaxResponseSize are used either way.
|
||||||
FetcherConfig *httpfetcher.Config
|
FetcherConfig *httpfetcher.Config
|
||||||
// Fetcher is an optional custom fetcher (for testing)
|
// Fetcher is an optional custom fetcher (for testing)
|
||||||
Fetcher httpfetcher.Fetcher
|
Fetcher httpfetcher.Fetcher
|
||||||
@@ -99,6 +100,13 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JPEG XL is the default output format, so pixad does not start
|
||||||
|
// without it.
|
||||||
|
err := imageprocessor.CheckJPEGXLSupport()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
maxResponseSize := fetcherCfg.MaxResponseSize
|
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||||
processor := imageprocessor.New(imageprocessor.Params{
|
processor := imageprocessor.New(imageprocessor.Params{
|
||||||
MaxInputBytes: maxResponseSize,
|
MaxInputBytes: maxResponseSize,
|
||||||
@@ -154,7 +162,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
// Fall through to re-process
|
// Fall through to re-process
|
||||||
} else {
|
} else {
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
|
s.cache.IncrementStats(ctx, true, 0)
|
||||||
|
|
||||||
return &ImageResponse{
|
return &ImageResponse{
|
||||||
Content: reader,
|
Content: reader,
|
||||||
@@ -171,7 +179,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
// failed or the request context has ended meanwhile
|
// failed or the request context has ended meanwhile
|
||||||
response, err := s.processOrWait(ctx, req)
|
response, err := s.processOrWait(ctx, req)
|
||||||
|
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), false, 0)
|
s.cache.IncrementStats(ctx, false, 0)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -292,14 +300,8 @@ func (s *Service) processOrWait(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
processingCtx := context.WithoutCancel(ctx)
|
processingCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
|
|
||||||
processingCtx, cancel = context.WithDeadline(processingCtx, deadline)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
|
||||||
|
|
||||||
return s.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
return s.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
||||||
})
|
})
|
||||||
@@ -336,6 +338,20 @@ func (s *Service) processOrWait(
|
|||||||
}, nil
|
}, 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
|
// loadCachedSource opens source content from cache, without reading it, and
|
||||||
// returns it with its size; nil if the cached data is unavailable, empty or
|
// returns it with its size; nil if the cached data is unavailable, empty or
|
||||||
// exceeds maxResponseSize.
|
// exceeds maxResponseSize.
|
||||||
@@ -445,7 +461,7 @@ func (s *Service) fetchAndProcess(
|
|||||||
fetchBytes := int64(len(sourceData))
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementUpstreamFetch(context.WithoutCancel(ctx), fetchBytes)
|
s.cache.IncrementUpstreamFetch(ctx, fetchBytes)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
@@ -527,7 +543,7 @@ func (s *Service) processAndStore(
|
|||||||
processDuration := time.Since(processStart)
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
|
s.cache.IncrementTransformCount(ctx)
|
||||||
|
|
||||||
// Read processed content
|
// Read processed content
|
||||||
processedData, err := io.ReadAll(processResult.Content)
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -430,6 +432,172 @@ 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) {
|
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,7 +90,6 @@ func TestStats_ZeroCounts(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,7 +118,6 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
})
|
})
|
||||||
@@ -162,7 +159,6 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
})
|
})
|
||||||
@@ -232,7 +228,6 @@ func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
|
|||||||
|
|
||||||
disabled, err := NewCache(enabled.db, CacheConfig{
|
disabled, err := NewCache(enabled.db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
DisableDiskCache: true,
|
DisableDiskCache: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ func SetupTestService(
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL parsing errors.
|
// URL parsing errors.
|
||||||
@@ -20,8 +22,9 @@ var (
|
|||||||
ErrPathTraversal = errors.New("path traversal detected")
|
ErrPathTraversal = errors.New("path traversal detected")
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxDimension is the maximum allowed width or height.
|
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||||
const MaxDimension = 8192
|
// processor's maximum for an output image.
|
||||||
|
const MaxDimension = imageprocessor.MaxOutputDimension
|
||||||
|
|
||||||
// ValidateDimension checks one requested width or height; name ("width" or
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
// "height") appears in the error. 0 means "original size" and is valid.
|
// "height") appears in the error. 0 means "original size" and is valid.
|
||||||
@@ -38,8 +41,10 @@ func ValidateDimension(name string, value int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
|
||||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
// and a size with no format, such as "800x600" or "orig"
|
||||||
|
var sizeFormatRegex = regexp.MustCompile(
|
||||||
|
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
|
||||||
|
|
||||||
// ParsedURL contains the parsed components of an image proxy URL.
|
// ParsedURL contains the parsed components of an image proxy URL.
|
||||||
type ParsedURL struct {
|
type ParsedURL struct {
|
||||||
@@ -56,12 +61,13 @@ type ParsedURL struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseImagePath parses the path captured by chi's wildcard:
|
// ParseImagePath parses the path captured by chi's wildcard:
|
||||||
// <host>/<path>/<size>.<format>
|
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL
|
||||||
// This is the primary entry point when using chi routing.
|
// This is the primary entry point when using chi routing.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||||
// - cdn.example.com/photos/cat.jpg/orig.png
|
// - cdn.example.com/photos/cat.jpg/orig.png
|
||||||
|
// - cdn.example.com/photos/cat.jpg/800x600
|
||||||
func ParseImagePath(path string) (*ParsedURL, error) {
|
func ParseImagePath(path string) (*ParsedURL, error) {
|
||||||
// Strip leading slash if present (chi may include it)
|
// Strip leading slash if present (chi may include it)
|
||||||
path = strings.TrimPrefix(path, "/")
|
path = strings.TrimPrefix(path, "/")
|
||||||
@@ -72,7 +78,8 @@ func ParseImagePath(path string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(path)
|
return parseImageComponents(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>
|
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>,
|
||||||
|
// or /v1/image/<host>/<path>/<size> for JPEG XL
|
||||||
// Use ParseImagePath instead when working with chi's wildcard capture.
|
// Use ParseImagePath instead when working with chi's wildcard capture.
|
||||||
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||||
// Remove the /v1/image/ prefix
|
// Remove the /v1/image/ prefix
|
||||||
@@ -89,7 +96,8 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(remainder)
|
return parseImageComponents(remainder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||||
|
// <host>/<path>/<size> for JPEG XL.
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||||
// Check for path traversal before any other processing
|
// Check for path traversal before any other processing
|
||||||
err := checkPathTraversal(remainder)
|
err := checkPathTraversal(remainder)
|
||||||
@@ -97,7 +105,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the last path segment which contains size.format
|
// Find the last path segment, which holds "size" or "size.format"
|
||||||
lastSlash := strings.LastIndex(remainder, "/")
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
if lastSlash == -1 {
|
if lastSlash == -1 {
|
||||||
return nil, ErrMissingSize
|
return nil, ErrMissingSize
|
||||||
@@ -212,7 +220,7 @@ func checkPathTraversal(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSizeFormat parses strings like "800x600.webp" or "orig.png"
|
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||||
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
@@ -225,11 +233,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if matches[4] == "orig" {
|
if matches[4] == "orig" {
|
||||||
// "orig.format" pattern
|
// "orig" or "orig.format" pattern
|
||||||
size = Size{Width: 0, Height: 0}
|
size = Size{Width: 0, Height: 0}
|
||||||
formatStr = matches[5]
|
formatStr = matches[5]
|
||||||
} else {
|
} else {
|
||||||
// "WxH.format" pattern
|
// "WxH" or "WxH.format" pattern
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.Atoi(matches[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
@@ -254,6 +262,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A URL that names no format is served, and signed, as JPEG XL
|
||||||
|
if formatStr == "" {
|
||||||
|
return size, FormatJXL, nil
|
||||||
|
}
|
||||||
|
|
||||||
format, err := parseFormat(formatStr)
|
format, err := parseFormat(formatStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
@@ -275,8 +288,12 @@ func parseFormat(s string) (ImageFormat, error) {
|
|||||||
return FormatWebP, nil
|
return FormatWebP, nil
|
||||||
case "avif":
|
case "avif":
|
||||||
return FormatAVIF, nil
|
return FormatAVIF, nil
|
||||||
|
case "jxl":
|
||||||
|
return FormatJXL, nil
|
||||||
case "gif":
|
case "gif":
|
||||||
return FormatGIF, nil
|
return FormatGIF, nil
|
||||||
|
case "auto":
|
||||||
|
return FormatAuto, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ 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) {
|
func TestParseImageURL_Errors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -26,6 +26,7 @@ const (
|
|||||||
MIMETypeWebP = MIMEType("image/webp")
|
MIMETypeWebP = MIMEType("image/webp")
|
||||||
MIMETypeGIF = MIMEType("image/gif")
|
MIMETypeGIF = MIMEType("image/gif")
|
||||||
MIMETypeAVIF = MIMEType("image/avif")
|
MIMETypeAVIF = MIMEType("image/avif")
|
||||||
|
MIMETypeJXL = MIMEType("image/jxl")
|
||||||
MIMETypeSVG = MIMEType("image/svg+xml")
|
MIMETypeSVG = MIMEType("image/svg+xml")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ const (
|
|||||||
FormatPNG ImageFormat = "png"
|
FormatPNG ImageFormat = "png"
|
||||||
FormatWebP ImageFormat = "webp"
|
FormatWebP ImageFormat = "webp"
|
||||||
FormatAVIF ImageFormat = "avif"
|
FormatAVIF ImageFormat = "avif"
|
||||||
|
FormatJXL ImageFormat = "jxl"
|
||||||
FormatGIF ImageFormat = "gif"
|
FormatGIF ImageFormat = "gif"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +64,11 @@ var (
|
|||||||
// AVIF uses the ftyp box with brand "avif" or "avis"
|
// AVIF uses the ftyp box with brand "avif" or "avis"
|
||||||
// Format: size(4 bytes) + "ftyp" + brand(4 bytes)
|
// Format: size(4 bytes) + "ftyp" + brand(4 bytes)
|
||||||
magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp"
|
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.
|
// WebP identifier appears at offset 8 after RIFF header.
|
||||||
@@ -115,6 +122,12 @@ 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
|
// Check SVG - look for XML declaration or SVG tag
|
||||||
if detectSVG(data) {
|
if detectSVG(data) {
|
||||||
return MIMETypeSVG, nil
|
return MIMETypeSVG, nil
|
||||||
@@ -181,7 +194,8 @@ func normalizeMIMEType(mimeType string) string {
|
|||||||
func IsSupportedMIMEType(mimeType string) bool {
|
func IsSupportedMIMEType(mimeType string) bool {
|
||||||
normalized := normalizeMIMEType(mimeType)
|
normalized := normalizeMIMEType(mimeType)
|
||||||
switch MIMEType(normalized) {
|
switch MIMEType(normalized) {
|
||||||
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF, MIMETypeSVG:
|
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF,
|
||||||
|
MIMETypeJXL, MIMETypeSVG:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -226,6 +240,8 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
|||||||
return FormatGIF, true
|
return FormatGIF, true
|
||||||
case MIMETypeAVIF:
|
case MIMETypeAVIF:
|
||||||
return FormatAVIF, true
|
return FormatAVIF, true
|
||||||
|
case MIMETypeJXL:
|
||||||
|
return FormatJXL, true
|
||||||
case MIMETypeSVG:
|
case MIMETypeSVG:
|
||||||
// SVG has no corresponding output format.
|
// SVG has no corresponding output format.
|
||||||
return "", false
|
return "", false
|
||||||
@@ -247,6 +263,8 @@ func ImageFormatToMIME(format ImageFormat) string {
|
|||||||
return string(MIMETypeGIF)
|
return string(MIMETypeGIF)
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return string(MIMETypeAVIF)
|
return string(MIMETypeAVIF)
|
||||||
|
case FormatJXL:
|
||||||
|
return string(MIMETypeJXL)
|
||||||
case FormatOriginal:
|
case FormatOriginal:
|
||||||
// Original format passes content through unchanged.
|
// Original format passes content through unchanged.
|
||||||
return mimeOctetStream
|
return mimeOctetStream
|
||||||
|
|||||||
@@ -35,13 +35,10 @@ const HSTSValue = "max-age=31536000; includeSubDomains"
|
|||||||
|
|
||||||
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
||||||
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
||||||
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
|
// clickjacking control.
|
||||||
// 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'; " +
|
const ContentSecurityPolicyValue = "default-src 'self'; " +
|
||||||
"script-src 'self' 'unsafe-inline'; " +
|
"script-src 'self'; " +
|
||||||
"style-src 'self' 'unsafe-inline'; " +
|
"style-src 'self'; " +
|
||||||
"object-src 'none'; " +
|
"object-src 'none'; " +
|
||||||
"base-uri 'self'; " +
|
"base-uri 'self'; " +
|
||||||
"form-action 'self'; " +
|
"form-action 'self'; " +
|
||||||
@@ -101,9 +98,10 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
|
|||||||
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
// 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.
|
// 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
|
// Counts are kept only for the current and the previous window, so memory
|
||||||
// stays bounded.
|
// stays bounded. limitHandler writes the 429 after Retry-After is set; nil
|
||||||
|
// keeps the rate limit library's plain-text "Too Many Requests".
|
||||||
func (s *Middleware) RateLimit(
|
func (s *Middleware) RateLimit(
|
||||||
requestLimit int, window time.Duration,
|
requestLimit int, window time.Duration, limitHandler http.HandlerFunc,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
return httprate.LimitBy(requestLimit, window,
|
return httprate.LimitBy(requestLimit, window,
|
||||||
func(r *http.Request) (string, error) {
|
func(r *http.Request) (string, error) {
|
||||||
@@ -115,7 +113,8 @@ func (s *Middleware) RateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return httprate.CanonicalizeIP(ip), nil
|
return httprate.CanonicalizeIP(ip), nil
|
||||||
})
|
},
|
||||||
|
httprate.WithLimitHandler(limitHandler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestIDPattern is what a request's own X-Request-Id must look like to be
|
// requestIDPattern is what a request's own X-Request-Id must look like to be
|
||||||
|
|||||||
@@ -325,6 +325,13 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
|||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// The login and generator pages load their script and stylesheet from
|
||||||
|
// /static, so the policy allows no inline script or style.
|
||||||
|
csp := rec.Header().Get("Content-Security-Policy")
|
||||||
|
if strings.Contains(csp, "unsafe-inline") {
|
||||||
|
t.Errorf("Content-Security-Policy allows unsafe-inline: %q", csp)
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
header string
|
header string
|
||||||
want string
|
want string
|
||||||
@@ -333,8 +340,8 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; " +
|
"default-src 'self'; " +
|
||||||
"script-src 'self' 'unsafe-inline'; " +
|
"script-src 'self'; " +
|
||||||
"style-src 'self' 'unsafe-inline'; " +
|
"style-src 'self'; " +
|
||||||
"object-src 'none'; " +
|
"object-src 'none'; " +
|
||||||
"base-uri 'self'; " +
|
"base-uri 'self'; " +
|
||||||
"form-action 'self'; " +
|
"form-action 'self'; " +
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"image/jpeg"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProxyRefusesComputedSideAboveMaximum requests sizes of a 3000x600
|
||||||
|
// and a 600x3000 PNG through both image routes. A size with one side 0, whose
|
||||||
|
// side computed from the source's proportions would be above 8192, is refused
|
||||||
|
// with 400 and the JSON error body. The sizes after those refusals are served
|
||||||
|
// as before, which also shows that a refusal is not cached as a failed fetch of
|
||||||
|
// the source: orig, a size with both sides given, and a size with one side 0
|
||||||
|
// whose computed side stays within 8192.
|
||||||
|
func TestImageProxyRefusesComputedSideAboveMaximum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The upstream paths of the two sources
|
||||||
|
const (
|
||||||
|
widePath = "/wide.png"
|
||||||
|
tallPath = "/tall.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
wide := encodeTestPNG(t, 3000, 600)
|
||||||
|
tall := encodeTestPNG(t, 600, 3000)
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
|
||||||
|
if r.URL.Path == tallPath {
|
||||||
|
_, _ = w.Write(tall)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = w.Write(wide)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, upstream)
|
||||||
|
|
||||||
|
generator, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
// size is the size of the /v1/image/ URL, and width and height are
|
||||||
|
// those of the encrypted URL
|
||||||
|
size string
|
||||||
|
width, height int
|
||||||
|
// wantWidth and wantHeight are the served image's, 0 for a refusal
|
||||||
|
wantWidth, wantHeight int
|
||||||
|
}{
|
||||||
|
{widePath, "0x8192", 0, 8192, 0, 0},
|
||||||
|
{tallPath, "8192x0", 8192, 0, 0, 0},
|
||||||
|
{widePath, "orig", 0, 0, 3000, 600},
|
||||||
|
{widePath, "300x200", 300, 200, 300, 200},
|
||||||
|
{widePath, "0x300", 0, 300, 1500, 300},
|
||||||
|
{tallPath, "300x0", 300, 0, 300, 1500},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
token, err := generator.Generate(&encurl.Payload{
|
||||||
|
SourceHost: upstreamHost, SourcePath: tt.path,
|
||||||
|
Width: tt.width, Height: tt.height, Format: imgcache.FormatJPEG,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range []string{
|
||||||
|
"/v1/image/" + upstreamHost + tt.path + "/" + tt.size + ".jpeg",
|
||||||
|
"/v1/e/" + token + "/img.jpg",
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
t.Logf("GET %s: %d, Content-Type %s",
|
||||||
|
target, rec.Code, rec.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
if tt.wantWidth == 0 {
|
||||||
|
checkSizeRefused(t, rec)
|
||||||
|
} else {
|
||||||
|
checkServedSize(t, rec, tt.wantWidth, tt.wantHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSizeRefused checks that rec answers 400 with the JSON error body that
|
||||||
|
// refuses a size whose side computed from the source's proportions is above
|
||||||
|
// 8192.
|
||||||
|
func checkSizeRefused(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const wantError = "size too large for the source: " +
|
||||||
|
"the side computed from its proportions is above 8192"
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("status %d, body is not JSON: %v", rec.Code, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest || body.Status != http.StatusBadRequest ||
|
||||||
|
body.Error != wantError {
|
||||||
|
t.Errorf("status %d, body %+v, want %d and the error %q",
|
||||||
|
rec.Code, body, http.StatusBadRequest, wantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkServedSize checks that rec answers 200 with a JPEG of wantWidth by
|
||||||
|
// wantHeight.
|
||||||
|
func checkServedSize(
|
||||||
|
t *testing.T, rec *httptest.ResponseRecorder, wantWidth, wantHeight int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status %d, want %d; body %s",
|
||||||
|
rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
served, err := jpeg.DecodeConfig(bytes.NewReader(rec.Body.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("decoding the served JPEG: %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if served.Width != wantWidth || served.Height != wantHeight {
|
||||||
|
t.Errorf("served %dx%d, want %dx%d",
|
||||||
|
served.Width, served.Height, wantWidth, wantHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProxyRefusesFitOutsideAboveMaximum requests sizes with fit=outside
|
||||||
|
// through both image routes. fit=outside scales the source to cover the size,
|
||||||
|
// and so past it on one side: a size for which that side would be above 8192
|
||||||
|
// is refused with 400 and the JSON error body, whether it is the width or the
|
||||||
|
// height, and a size for which it stays within 8192 is served at the scaled
|
||||||
|
// size.
|
||||||
|
func TestImageProxyRefusesFitOutsideAboveMaximum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The upstream paths of the sources
|
||||||
|
const (
|
||||||
|
widePath = "/wide.png"
|
||||||
|
rowPath = "/row.png"
|
||||||
|
columnPath = "/column.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
sources := map[string][]byte{
|
||||||
|
widePath: encodeTestPNG(t, 3000, 600),
|
||||||
|
rowPath: encodeTestPNG(t, 8192, 1),
|
||||||
|
columnPath: encodeTestPNG(t, 1, 8192),
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(sources[r.URL.Path])
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, upstream)
|
||||||
|
|
||||||
|
generator, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
width, height int
|
||||||
|
// wantWidth and wantHeight are the served image's, 0 for a refusal
|
||||||
|
wantWidth, wantHeight int
|
||||||
|
}{
|
||||||
|
{widePath, 1, 8192, 0, 0},
|
||||||
|
{rowPath, 1, 8192, 0, 0},
|
||||||
|
{columnPath, 8192, 1, 0, 0},
|
||||||
|
{widePath, 1, 1200, 6000, 1200},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
token, err := generator.Generate(&encurl.Payload{
|
||||||
|
SourceHost: upstreamHost, SourcePath: tt.path,
|
||||||
|
Width: tt.width, Height: tt.height, Format: imgcache.FormatJPEG,
|
||||||
|
FitMode: imgcache.FitOutside,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fmt.Sprintf("%dx%d", tt.width, tt.height)
|
||||||
|
|
||||||
|
for _, target := range []string{
|
||||||
|
"/v1/image/" + upstreamHost + tt.path + "/" + size +
|
||||||
|
".jpeg?fit=outside",
|
||||||
|
"/v1/e/" + token + "/img.jpg",
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
t.Logf("GET %s: %d, Content-Type %s",
|
||||||
|
target, rec.Code, rec.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
if tt.wantWidth == 0 {
|
||||||
|
checkSizeRefused(t, rec)
|
||||||
|
} else {
|
||||||
|
checkServedSize(t, rec, tt.wantWidth, tt.wantHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFormatAutoVaryNextToOrigin requests an image URL with the format auto
|
||||||
|
// through the server's routes and verifies that the answer carries
|
||||||
|
// Vary: Accept next to the Vary: Origin the CORS middleware sends.
|
||||||
|
func TestFormatAutoVaryNextToOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := encodeTestPNG(t, 64, 48)
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(source)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, upstream)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+upstreamHost+"/photo.png/32x24.auto", nil)
|
||||||
|
req.Header.Set("Origin", "https://app.example.com")
|
||||||
|
req.Header.Set("Accept", "image/webp")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
vary := rec.Header().Values("Vary")
|
||||||
|
t.Logf("status %d, Content-Type %s, Vary %v",
|
||||||
|
rec.Code, rec.Header().Get("Content-Type"), vary)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := []string{"Origin", "Accept"}; !slices.Equal(vary, want) {
|
||||||
|
t.Errorf("Vary = %v, want %v", vary, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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/handlers"
|
||||||
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// upstreamHost is the upstream host of the image URLs below. It is a
|
||||||
|
// documentation address (RFC 5737), which the fetcher's URL check accepts as
|
||||||
|
// public; the fetcher's dial function connects it to the test upstream server.
|
||||||
|
const upstreamHost = "192.0.2.10"
|
||||||
|
|
||||||
|
// TestImageProxyFlow requests images through pixa's router, handlers,
|
||||||
|
// upstream fetcher, image processor, disk cache and database, with only the
|
||||||
|
// upstream origin replaced by a local test server. The first request for a URL
|
||||||
|
// is fetched and converted; the second is served from the cache without
|
||||||
|
// another upstream request. The source and the converted image are then on
|
||||||
|
// disk, with their rows in the database.
|
||||||
|
func TestImageProxyFlow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := encodeTestPNG(t, 64, 48)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sizeFormat string // the <size>.<format> part of the image URL
|
||||||
|
contentType string
|
||||||
|
decodeConfig func(io.Reader) (image.Config, error)
|
||||||
|
width, height int
|
||||||
|
}{
|
||||||
|
{"resize and convert to JPEG", "32x24.jpeg", "image/jpeg",
|
||||||
|
jpeg.DecodeConfig, 32, 24},
|
||||||
|
{"orig", "orig.orig", "image/png", png.DecodeConfig, 64, 48},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var upstreamRequests atomic.Int32
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
upstreamRequests.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(source)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, db, stateDir := startImageProxy(t, upstream)
|
||||||
|
target := "/v1/image/" + upstreamHost + "/photo.png/" + tt.sizeFormat
|
||||||
|
|
||||||
|
first := getImage(t, s, target)
|
||||||
|
|
||||||
|
if got := first.Header().Get("X-Pixa-Cache"); got != "MISS" {
|
||||||
|
t.Errorf("first X-Pixa-Cache = %q, want MISS", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := first.Header().Get("Content-Type"); got != tt.contentType {
|
||||||
|
t.Errorf("Content-Type = %q, want %q", got, tt.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := tt.decodeConfig(bytes.NewReader(first.Body.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding the image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Width != tt.width || decoded.Height != tt.height {
|
||||||
|
t.Errorf("image is %dx%d, want %dx%d",
|
||||||
|
decoded.Width, decoded.Height, tt.width, tt.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := getImage(t, s, target)
|
||||||
|
|
||||||
|
if got := second.Header().Get("X-Pixa-Cache"); got != "HIT" {
|
||||||
|
t.Errorf("second X-Pixa-Cache = %q, want HIT", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(second.Body.Bytes(), first.Body.Bytes()) {
|
||||||
|
t.Error("the second response is not the image the first served")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := upstreamRequests.Load(); got != 1 {
|
||||||
|
t.Errorf("upstream received %d requests, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSourceCached(t, db, stateDir, source)
|
||||||
|
checkVariantCached(t, db, stateDir, first.Body.Bytes(), tt.contentType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startImageProxy starts the components pixad's fx app builds, from a config
|
||||||
|
// with a fresh state directory and upstreamHost on the allowlist, and with an
|
||||||
|
// upstream fetcher that connects every upstream address to upstream. It
|
||||||
|
// returns the server with its routes, the database and the state directory.
|
||||||
|
func startImageProxy(
|
||||||
|
t *testing.T, upstream *httptest.Server,
|
||||||
|
) (*Server, *sql.DB, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
StateDir: stateDir,
|
||||||
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
|
AllowlistHosts: []string{upstreamHost},
|
||||||
|
// The test upstream server has no TLS.
|
||||||
|
AllowHTTP: true,
|
||||||
|
// A limit of its own, so the cache does not size itself from the
|
||||||
|
// host's free disk space.
|
||||||
|
CacheMaxBytes: 64 << 20,
|
||||||
|
CacheMaxBytesExplicit: true,
|
||||||
|
UpstreamMaxResponseSize: config.DefaultUpstreamMaxResponseSize,
|
||||||
|
DownstreamTimeout: config.DefaultDownstreamTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
|
fetcherCfg.AllowHTTP = true
|
||||||
|
fetcherCfg.DialContext = func(
|
||||||
|
ctx context.Context, network, _ string,
|
||||||
|
) (net.Conn, error) {
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
return dialer.DialContext(ctx, network, upstream.Listener.Addr().String())
|
||||||
|
}
|
||||||
|
fetcher := httpfetcher.New(fetcherCfg)
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
mw *middleware.Middleware
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Supply(cfg),
|
||||||
|
fx.Provide(
|
||||||
|
globals.New,
|
||||||
|
logger.New,
|
||||||
|
database.New,
|
||||||
|
healthcheck.New,
|
||||||
|
handlers.New,
|
||||||
|
middleware.New,
|
||||||
|
func() httpfetcher.Fetcher { return fetcher },
|
||||||
|
),
|
||||||
|
fx.Populate(&h, &mw, &db),
|
||||||
|
)
|
||||||
|
app.RequireStart()
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// Requests go straight to the router, as in newTestServer; the server's
|
||||||
|
// own start hook, which listens on a port, is left out.
|
||||||
|
s := &Server{config: cfg, mw: mw, h: h}
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
return s, db.DB(), stateDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// getImage sends a GET for target to s and fails unless it answers 200.
|
||||||
|
func getImage(t *testing.T, s *Server, target string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
t.Logf("GET %s: %d, X-Pixa-Cache %s",
|
||||||
|
target, rec.Code, rec.Header().Get("X-Pixa-Cache"))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s status = %d, want %d; body %s",
|
||||||
|
target, rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSourceCached checks that source is stored under its SHA-256 in
|
||||||
|
// cache/sources, recorded in source_content, and that the source URL's row in
|
||||||
|
// source_metadata points at it.
|
||||||
|
func checkSourceCached(t *testing.T, db *sql.DB, stateDir string, source []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sum := sha256.Sum256(source)
|
||||||
|
hash := hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
checkFile(t, filepath.Join(stateDir, "cache", "sources", hash[0:2], hash[2:4], hash),
|
||||||
|
source)
|
||||||
|
|
||||||
|
var rows int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(t.Context(),
|
||||||
|
"SELECT COUNT(*) FROM source_content WHERE content_hash = ?", hash,
|
||||||
|
).Scan(&rows)
|
||||||
|
if err != nil || rows != 1 {
|
||||||
|
t.Errorf("source_content rows for the source = %d (error %v), want 1",
|
||||||
|
rows, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadataHash string
|
||||||
|
|
||||||
|
err = db.QueryRowContext(t.Context(),
|
||||||
|
`SELECT content_hash FROM source_metadata
|
||||||
|
WHERE source_host = ? AND source_path = ?`,
|
||||||
|
upstreamHost, "/photo.png",
|
||||||
|
).Scan(&metadataHash)
|
||||||
|
if err != nil || metadataHash != hash {
|
||||||
|
t.Errorf("source_metadata content_hash = %q (error %v), want %q",
|
||||||
|
metadataHash, err, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVariantCached checks that the converted image served is recorded in
|
||||||
|
// variant_content with contentType, and stored under its cache key in
|
||||||
|
// cache/variants.
|
||||||
|
func checkVariantCached(
|
||||||
|
t *testing.T, db *sql.DB, stateDir string, served []byte, contentType string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cacheKey, storedType string
|
||||||
|
|
||||||
|
err := db.QueryRowContext(t.Context(),
|
||||||
|
"SELECT cache_key, content_type FROM variant_content",
|
||||||
|
).Scan(&cacheKey, &storedType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant_content row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedType != contentType {
|
||||||
|
t.Errorf("variant_content content_type = %q, want %q",
|
||||||
|
storedType, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFile(t, filepath.Join(stateDir, "cache", "variants",
|
||||||
|
cacheKey[0:2], cacheKey[2:4], cacheKey), served)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFile checks that the file at path holds want.
|
||||||
|
func checkFile(t *testing.T, path string, want []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
//nolint:gosec // G304: a path under the test's state directory
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("reading %s: %v", path, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("%s holds %d bytes that are not the %d expected",
|
||||||
|
path, len(got), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeTestPNG returns an opaque width x height PNG of one color.
|
||||||
|
func encodeTestPNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
img.Set(x, y, color.RGBA{R: 200, G: 40, B: 40, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoding the test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requestImageFrom sends method path to s from remoteAddr and returns the
|
||||||
|
// response. The image handlers refuse an unsigned image URL with 401 and a
|
||||||
|
// token they cannot decrypt with 400, so either status shows the limit let
|
||||||
|
// the request through.
|
||||||
|
func requestImageFrom(
|
||||||
|
t *testing.T, s *Server, method, path, remoteAddr string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
|
||||||
|
req.RemoteAddr = remoteAddr
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRequestLimitRefusesRequestOverLimit verifies that GET and HEAD on
|
||||||
|
// both image routes count toward one limit per client, and that the request
|
||||||
|
// after image_requests_per_minute of them is refused with 429 and a
|
||||||
|
// Retry-After header.
|
||||||
|
func TestImageRequestLimitRefusesRequestOverLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
requests := []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
||||||
|
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
||||||
|
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.config.ImageRequestsPerMinute = len(requests)
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
for _, tc := range requests {
|
||||||
|
rec := requestImageFrom(t, s, tc.method, tc.path, firstClient)
|
||||||
|
if rec.Code != tc.want {
|
||||||
|
t.Fatalf("%s %s within the limit status = %d, want %d",
|
||||||
|
tc.method, tc.path, rec.Code, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := requestImageFrom(t, s, http.MethodHead, encryptedImagePath, firstClient)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("request over the limit status = %d, want %d",
|
||||||
|
rec.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAfter := rec.Header().Get("Retry-After")
|
||||||
|
|
||||||
|
seconds, err := strconv.Atoi(retryAfter)
|
||||||
|
if err != nil || seconds <= 0 {
|
||||||
|
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||||
|
retryAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRequestLimitAnswersWithJSONError verifies that the 429 for a
|
||||||
|
// request over the limit carries Retry-After and the JSON error body the
|
||||||
|
// image routes send for their other errors.
|
||||||
|
func TestImageRequestLimitAnswersWithJSONError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.config.ImageRequestsPerMinute = 1
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||||
|
|
||||||
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||||
|
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("request over the limit status = %d, want %d",
|
||||||
|
rec.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAfter := rec.Header().Get("Retry-After")
|
||||||
|
|
||||||
|
seconds, err := strconv.Atoi(retryAfter)
|
||||||
|
if err != nil || seconds <= 0 {
|
||||||
|
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||||
|
retryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := rec.Header().Get("Content-Type")
|
||||||
|
if contentType != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("body is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Error == "" || body.Status != http.StatusTooManyRequests ||
|
||||||
|
body.Timestamp == "" {
|
||||||
|
t.Errorf("body = %+v, want an error, status %d and a timestamp",
|
||||||
|
body, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRequestLimitCountsClientsSeparately verifies that one client going
|
||||||
|
// over the limit does not limit another.
|
||||||
|
func TestImageRequestLimitCountsClientsSeparately(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.config.ImageRequestsPerMinute = 1
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("first client's first request status = %d, want %d",
|
||||||
|
rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("first client's second request status = %d, want %d",
|
||||||
|
rec.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, secondClient)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("second client's first request status = %d, want %d",
|
||||||
|
rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRequestLimitZeroLetsEveryRequestThrough verifies that with
|
||||||
|
// image_requests_per_minute set to 0 no image request is refused with 429,
|
||||||
|
// not even past the default limit.
|
||||||
|
func TestImageRequestLimitZeroLetsEveryRequestThrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.config.ImageRequestsPerMinute = 0
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
for request := range config.DefaultImageRequestsPerMinute + 1 {
|
||||||
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("request %d status = %d, want %d",
|
||||||
|
request+1, rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRequestLimitDoesNotCountPreflight verifies that a browser's CORS
|
||||||
|
// preflight request to an image route is answered without being counted, so
|
||||||
|
// a client that has sent several still gets its image request through.
|
||||||
|
func TestImageRequestLimitDoesNotCountPreflight(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.config.AccessControlAllowOrigin = config.DefaultAccessControlAllowOrigin
|
||||||
|
s.config.ImageRequestsPerMinute = 1
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodOptions, unsignedImagePath, nil)
|
||||||
|
req.RemoteAddr = firstClient
|
||||||
|
req.Header.Set("Origin", "https://app.example.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("image request after preflights status = %d, want %d",
|
||||||
|
rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProxyFlowJPEGXL requests the JPEG XL testdata/red.jxl, 64x48 and
|
||||||
|
// made with libvips, through pixa's router, upstream fetcher, image processor
|
||||||
|
// and disk cache: resized as PNG, resized as JPEG XL, and at its own size and
|
||||||
|
// format, which is JPEG XL. The second request for each URL is a cache hit.
|
||||||
|
func TestImageProxyFlowJPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source, err := os.ReadFile("testdata/red.jxl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/jxl")
|
||||||
|
_, _ = w.Write(source)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, upstream)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
sizeFormat string // the <size>.<format> part of the image URL
|
||||||
|
contentType string
|
||||||
|
imageType vips.ImageType
|
||||||
|
width, height int
|
||||||
|
}{
|
||||||
|
{"32x24.png", "image/png", vips.ImageTypePNG, 32, 24},
|
||||||
|
{"32x24.jxl", "image/jxl", vips.ImageTypeJXL, 32, 24},
|
||||||
|
{"orig.orig", "image/jxl", vips.ImageTypeJXL, 64, 48},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
target := "/v1/image/" + upstreamHost + "/red.jxl/" + tt.sizeFormat
|
||||||
|
|
||||||
|
for _, wantCache := range []string{"MISS", "HIT"} {
|
||||||
|
rec := getImage(t, s, target)
|
||||||
|
gotType := rec.Header().Get("Content-Type")
|
||||||
|
gotCache := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
|
||||||
|
if gotType != tt.contentType || gotCache != wantCache {
|
||||||
|
t.Errorf("%s: %s, X-Pixa-Cache %s, want %s, %s",
|
||||||
|
target, gotType, gotCache, tt.contentType, wantCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := vips.NewImageFromBuffer(rec.Body.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: loading the image: %v", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if img.Format() != tt.imageType ||
|
||||||
|
img.Width() != tt.width || img.Height() != tt.height {
|
||||||
|
t.Errorf("%s: %s %dx%d, want %s %dx%d", target,
|
||||||
|
vips.ImageTypes[img.Format()], img.Width(), img.Height(),
|
||||||
|
vips.ImageTypes[tt.imageType], tt.width, tt.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Robots.txt
|
// Robots.txt
|
||||||
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
||||||
|
|
||||||
// Static files (Tailwind CSS, etc.)
|
// The login and generator pages' stylesheet and script
|
||||||
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
||||||
|
|
||||||
// Login/generator UI. The form routes carry CSRF protection; the
|
// Login/generator UI. The form routes carry CSRF protection; the
|
||||||
@@ -66,7 +66,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||||
r.Use(s.h.CSRF())
|
r.Use(s.h.CSRF())
|
||||||
r.Get("/", s.h.HandleRoot())
|
r.Get("/", s.h.HandleRoot())
|
||||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
|
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)).
|
||||||
Post("/", s.h.HandleRoot())
|
Post("/", s.h.HandleRoot())
|
||||||
r.Post("/generate", s.h.HandleGenerateURL())
|
r.Post("/generate", s.h.HandleGenerateURL())
|
||||||
})
|
})
|
||||||
@@ -88,8 +88,18 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.refuseDuringMaintenance)
|
r.Use(s.refuseDuringMaintenance)
|
||||||
|
|
||||||
|
// Limited per client, as the login POST is, but the 429 has
|
||||||
|
// the image routes' JSON error body; 0 turns the limit off.
|
||||||
|
// The CORS middleware answers a preflight before it gets
|
||||||
|
// here, so a preflight is not counted.
|
||||||
|
if s.config.ImageRequestsPerMinute > 0 {
|
||||||
|
r.Use(s.mw.RateLimit(s.config.ImageRequestsPerMinute,
|
||||||
|
time.Minute, s.h.HandleTooManyRequests()))
|
||||||
|
}
|
||||||
|
|
||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
|
||||||
|
// format /v1/image/<host>/<path>/<width>x<height>
|
||||||
r.Get("/image/*", s.h.HandleImage())
|
r.Get("/image/*", s.h.HandleImage())
|
||||||
r.Head("/image/*", s.h.HandleImage())
|
r.Head("/image/*", s.h.HandleImage())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// svgServedAsAVIF is an SVG whose first bytes pass the first-bytes check for
|
||||||
|
// AVIF: its XML comment puts "ftypavis" at bytes 4 to 11, where an AVIF file
|
||||||
|
// has its ftyp box and brand. libvips would load it as SVG.
|
||||||
|
const svgServedAsAVIF = `<!--ftypavis--><svg xmlns="http://www.w3.org/2000/svg" ` +
|
||||||
|
`width="64" height="48"><rect width="64" height="48"/></svg>`
|
||||||
|
|
||||||
|
// TestImageProxyRefusesSVGServedAsAVIF requests svgServedAsAVIF, which the
|
||||||
|
// upstream answers as image/avif, through both image routes, resized as PNG
|
||||||
|
// and in its own format (orig). Each request is answered 415, as for an SVG
|
||||||
|
// source, never with an image.
|
||||||
|
func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/avif")
|
||||||
|
_, _ = io.WriteString(w, svgServedAsAVIF)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, upstream)
|
||||||
|
|
||||||
|
generator, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedURL := func(format imgcache.ImageFormat) string {
|
||||||
|
token, err := generator.Generate(&encurl.Payload{
|
||||||
|
SourceHost: upstreamHost, SourcePath: "/drawing.avif",
|
||||||
|
Width: 32, Height: 24, Format: format,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/v1/e/" + token + "/drawing." + string(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range []string{
|
||||||
|
"/v1/image/" + upstreamHost + "/drawing.avif/32x24.png",
|
||||||
|
"/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig",
|
||||||
|
encryptedURL(imgcache.FormatPNG),
|
||||||
|
encryptedURL(imgcache.FormatOriginal),
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
t.Logf("GET %s: %d, Content-Type %s, body %q",
|
||||||
|
target, rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnsupportedMediaType ||
|
||||||
|
!strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||||
|
t.Errorf("GET %s: %d %q, want %d unsupported source format",
|
||||||
|
target, rec.Code, rec.Body.String(), http.StatusUnsupportedMediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProxyRefusesSVG requests an image the upstream answers as SVG,
|
||||||
|
// in its own format (orig) and resized as PNG, through pixa's router,
|
||||||
|
// upstream fetcher and image processor. pixa does not accept SVG as a
|
||||||
|
// source, so each request is answered 415, never with an image.
|
||||||
|
func TestImageProxyRefusesSVG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
_, _ = io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg" `+
|
||||||
|
`width="64" height="48"><rect width="64" height="48"/></svg>`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, upstream)
|
||||||
|
|
||||||
|
for _, sizeFormat := range []string{"64x48.orig", "32x24.png"} {
|
||||||
|
target := "/v1/image/" + upstreamHost + "/drawing.svg/" + sizeFormat
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
t.Logf("GET %s: %d, Content-Type %s, body %s",
|
||||||
|
target, rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("GET %s status = %d, want %d",
|
||||||
|
target, rec.Code, http.StatusUnsupportedMediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
// Generator page: a click on the generated URL selects it, and the Copy
|
||||||
|
// button copies it. Both are on the page only once a URL has been generated.
|
||||||
|
const generatedURL = document.getElementById("generated-url");
|
||||||
|
|
||||||
|
if (generatedURL) {
|
||||||
|
generatedURL.addEventListener("click", () => generatedURL.select());
|
||||||
|
document.getElementById("copy-url").addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(generatedURL.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed *.js
|
//go:embed *.css *.js
|
||||||
var files embed.FS
|
var files embed.FS
|
||||||
|
|
||||||
// FS returns the embedded filesystem containing static files.
|
// FS returns the embedded filesystem containing static files.
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/* The login and generator pages. */
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f3f4f6;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form > * + * {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgb(0 0 0 / 10%),
|
||||||
|
0 2px 4px -2px rgb(0 0 0 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #f87171;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login page: the card centred on the screen. */
|
||||||
|
|
||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login .card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login h1 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generator page. */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result div {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result input {
|
||||||
|
flex: 1;
|
||||||
|
border-color: #86efac;
|
||||||
|
box-shadow: none;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result button {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #16a34a;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result button:hover {
|
||||||
|
background: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user