Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b11e9703 | ||
|
|
5f9d624d74 | ||
|
|
a19cca2f34 | ||
|
|
00d8ba26c5 | ||
|
|
385c0ca76f | ||
|
|
989da78681 | ||
|
|
4fb6350aa1 | ||
|
|
cde9c0d10d | ||
|
|
957ec85f97 | ||
|
|
930e43961e | ||
|
|
9eacfa6406 | ||
|
|
0ab15324f7 | ||
|
|
faad959bea | ||
|
|
3a6b3bc202 | ||
|
|
08d6c3c459 | ||
|
|
efa431ba18 | ||
|
|
8b4dcda549 | ||
|
|
6d271d4617 | ||
|
|
4a4bc9758f | ||
|
|
9a96740463 | ||
|
|
1fd5cf8bd0 | ||
|
|
62b4f4791e | ||
|
|
22bfc432b6 | ||
|
|
f743930f71 | ||
|
|
4e30dc7467 | ||
|
|
16f9d1dfd1 | ||
|
|
742db9ceb1 | ||
|
|
2ab96155ba | ||
|
|
3ba9ae9c39 | ||
|
|
a3aa73f6fd | ||
|
|
7956da1f59 | ||
|
|
69f65d4302 | ||
|
|
2790125cb2 | ||
|
|
c23585dd6d | ||
|
|
5d1e3ea871 | ||
|
|
761ade2e36 | ||
|
|
88e1b1cdfa | ||
|
|
afb5dc5d0d | ||
|
|
d8760c40a1 | ||
|
|
ca512fdb4f | ||
|
|
52f69d3082 | ||
|
|
162a2a33ef | ||
|
|
40ad39843b | ||
|
|
bdde021b45 | ||
|
|
db91ab29e6 | ||
|
|
8597253ffd | ||
|
|
99e4aa3bb2 | ||
|
|
d2944aa891 | ||
|
|
54328377d0 | ||
|
|
5058fd532b | ||
|
|
15d95436a5 | ||
|
|
01823d27db | ||
|
|
a941a80bf9 | ||
|
|
55cf7f4fac | ||
|
|
c434581a54 | ||
|
|
f77faf13de | ||
|
|
ae7c3f226d | ||
|
|
2beba15ae7 |
+23
-9
@@ -13,12 +13,21 @@
|
|||||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
# deletes the package directory from the context.
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
# Unlike the standard file, which leaves out all of .git, pixa sends
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
# .git without its config. Without a VERSION build argument the stage
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
# that compiles runs `git describe --tags --always` on .git, which does
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
# 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
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
|
# its own .git directory when it keeps one.
|
||||||
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
|
# `**/.git/modules/**/config` also matches that segment's directory
|
||||||
|
# under .git/modules/. Go's version stamping then fails the build;
|
||||||
|
# nothing leaks. Name such a submodule without that segment:
|
||||||
|
# `git submodule add --name`.
|
||||||
|
**/.git/config
|
||||||
|
**/.git/modules/**/config
|
||||||
|
|
||||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
# Anchored because it occurs once where agents run at the repo root.
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
@@ -42,7 +51,9 @@
|
|||||||
**/[iI][dD]_[rR][sS][aA]
|
**/[iI][dD]_[rR][sS][aA]
|
||||||
**/[iI][dD]_[dD][sS][aA]
|
**/[iI][dD]_[dD][sS][aA]
|
||||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
**/[iI][dD]_[eE][dD]25519
|
**/[iI][dD]_[eE][dD]25519
|
||||||
|
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
# Dependencies: restored inside the image, never copied in.
|
# Dependencies: restored inside the image, never copied in.
|
||||||
**/node_modules
|
**/node_modules
|
||||||
@@ -60,9 +71,12 @@
|
|||||||
**/.vscode
|
**/.vscode
|
||||||
**/*.sublime-*
|
**/*.sublime-*
|
||||||
|
|
||||||
# pixa's own entries. Nothing in the build reads .gitignore. On the
|
# pixa's own entries. On the host, `make build` writes bin/pixad, and the
|
||||||
# host, `make build` writes bin/pixad, and the example config keeps its
|
# example config keeps its state directory in data/.
|
||||||
# state directory in data/.
|
|
||||||
.gitignore
|
|
||||||
/bin
|
/bin
|
||||||
/data
|
/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]
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ indent_style = tab
|
|||||||
|
|
||||||
[*.go]
|
[*.go]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
|
# This repository's own sections, such as one for another language it
|
||||||
|
# uses, go below this comment, and a re-vendor keeps them.
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
name: check
|
name: check
|
||||||
on: [push]
|
on: [push]
|
||||||
|
# Free the shared runner: a new push cancels only the same branch's older run.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Free the shared runner from a hung build.
|
||||||
|
timeout-minutes: 20
|
||||||
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
|
||||||
|
# script/cibuild needs no token, so none is left in .git/config.
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
# All history and tags, so git describe finds the version tag.
|
||||||
|
fetch-depth: 0
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
- run: script/docker-smoke
|
|
||||||
|
|||||||
+38
-6
@@ -17,15 +17,46 @@ Thumbs.db
|
|||||||
# entry and must not be given a `**/` prefix on the way into one.
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Environment / secrets
|
# Node
|
||||||
.env
|
node_modules/
|
||||||
.env.*
|
|
||||||
*.pem
|
# Secrets. Unanchored like every entry above, so each matches at every
|
||||||
*.key
|
# depth. Matching is case-sensitive on Linux, so names use character
|
||||||
|
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Only the templates `example.env` and `sample.env` are
|
||||||
|
# re-included below. A repository that commits any other template adds
|
||||||
|
# its own negation at the end of this file, for example `!.env.example`.
|
||||||
|
*.[eE][nN][vV]
|
||||||
|
.[eE][nN][vV].*
|
||||||
|
.[eE][nN][vV][rR][cC]
|
||||||
|
!example.env
|
||||||
|
!sample.env
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them.
|
||||||
|
*.[pP][eE][mM]
|
||||||
|
*.[kK][eE][yY]
|
||||||
|
*.[pP]12
|
||||||
|
*.[pP][fF][xX]
|
||||||
|
[iI][dD]_[rR][sS][aA]
|
||||||
|
[iI][dD]_[dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
[iI][dD]_[eE][dD]25519
|
||||||
|
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# This repository's own entries, such as its build outputs, go below
|
||||||
|
# this comment, and a re-vendor keeps them. Anchor a binary built at the
|
||||||
|
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
vendor/
|
vendor/
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/bin/
|
/bin/
|
||||||
@@ -37,5 +68,6 @@ node_modules/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# Local dev configs
|
# Local dev configs
|
||||||
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
config.dev.yml
|
config.dev.yml
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ linters:
|
|||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
@@ -24,6 +25,8 @@ linters:
|
|||||||
# silenced by disabling that name, not by enabling the successor.
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
|
# Misses findings at random in v2.14.0; back once a pinned release fixes it
|
||||||
|
- canonicalheader
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Repository Rules
|
||||||
|
|
||||||
|
Last Updated 2026-01-08
|
||||||
|
|
||||||
|
These rules MUST be followed at all times, it is very important.
|
||||||
|
|
||||||
|
- Never use `git add -A` - add specific changes to a deliberate commit. A commit
|
||||||
|
should contain one change. After each change, make a commit with a good
|
||||||
|
one-line summary.
|
||||||
|
|
||||||
|
- NEVER modify the linter config without asking first.
|
||||||
|
|
||||||
|
- NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||||
|
without asking first. In almost all cases, the code should be changed, NOT the
|
||||||
|
tests. If you think the test needs to be changed, make your case for that and
|
||||||
|
ask for permission to proceed, then stop. You need explicit user approval to
|
||||||
|
modify existing tests. A bug fix may change an existing test without asking
|
||||||
|
when that test pins exactly the behavior the filed bug calls wrong, and the
|
||||||
|
change moves it to the fixed behavior and nothing else; any other change to an
|
||||||
|
existing test still needs explicit user approval. (You do not need user
|
||||||
|
approval for writing NEW tests.)
|
||||||
|
|
||||||
|
- When linting, assume the linter config is CORRECT, and that each item output
|
||||||
|
by the linter is something that legitimately needs fixing in the code.
|
||||||
|
|
||||||
|
- When running tests, use `make test`.
|
||||||
|
|
||||||
|
- Before commits, run `make check`. This runs `make lint` and `make test` and
|
||||||
|
`make check-fmt`. Any issues discovered MUST be resolved before committing
|
||||||
|
unless explicitly told otherwise.
|
||||||
|
|
||||||
|
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate
|
||||||
|
logging to the test to ensure it is written correctly. Commit that. Then go
|
||||||
|
about fixing the bug until the test passes (without modifying the test
|
||||||
|
further). Then commit that.
|
||||||
|
|
||||||
|
- When adding a new feature, do the same - implement a test first (TDD). It
|
||||||
|
doesn't have to be super complex. Commit the test, then commit the feature.
|
||||||
|
|
||||||
|
- When adding a new feature, use a feature branch. When the feature is
|
||||||
|
completely finished and the code is up to standards (passes `make check`) then
|
||||||
|
and only then can the feature branch be merged into `main` and the branch
|
||||||
|
deleted.
|
||||||
|
|
||||||
|
- Write godoc documentation comments for all exported types and functions as you
|
||||||
|
go along.
|
||||||
|
|
||||||
|
- ALWAYS be consistent in naming. If you name something one thing in one place,
|
||||||
|
name it the EXACT SAME THING in another place.
|
||||||
|
|
||||||
|
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
|
||||||
|
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
||||||
|
|
||||||
|
- This is not prototype or teaching code - this is designed for production. Any
|
||||||
|
security issues (such as denial of service) or other web vulnerabilities are
|
||||||
|
P1 bugs and must be added to TODO.md at the top.
|
||||||
|
|
||||||
|
- As this is production code, no stubbing of implementations unless specifically
|
||||||
|
instructed. We need working implementations.
|
||||||
|
|
||||||
|
- NEVER silently fall back to a different setting when a user's parameter
|
||||||
|
explicitly specifies a value. If a user requests format=webp and WebP encoding
|
||||||
|
is not supported, return an error - do NOT silently output PNG instead. If a
|
||||||
|
user specifies fit=invalid and that fit mode doesn't exist, return an error -
|
||||||
|
do NOT silently default to "cover". Silent fallbacks violate the principle of
|
||||||
|
least surprise and mask bugs. The only acceptable defaults are for OMITTED
|
||||||
|
parameters, never for INVALID explicit values.
|
||||||
|
|
||||||
|
- Avoid vendoring deps unless specifically instructed to. NEVER commit the
|
||||||
|
vendor directory, NEVER commit compiled binaries. If these directories or
|
||||||
|
files exist, add them to .gitignore (and commit the .gitignore) if they are
|
||||||
|
not already in there. Keep the entire git repository (with history) small -
|
||||||
|
under 20MiB, unless you specifically must commit larger files (e.g. test
|
||||||
|
fixture example media files). Only OUR source code and immediately supporting
|
||||||
|
files (such as test examples) goes into the repo/history.
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Repository Rules
|
|
||||||
|
|
||||||
Last Updated 2026-01-08
|
|
||||||
|
|
||||||
These rules MUST be followed at all times, it is very important.
|
|
||||||
|
|
||||||
* Never use `git add -A` - add specific changes to a deliberate commit. A
|
|
||||||
commit should contain one change. After each change, make a commit with a
|
|
||||||
good one-line summary.
|
|
||||||
|
|
||||||
* NEVER modify the linter config without asking first.
|
|
||||||
|
|
||||||
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
|
||||||
without asking first. In almost all cases, the code should be changed,
|
|
||||||
NOT the tests. If you think the test needs to be changed, make your case
|
|
||||||
for that and ask for permission to proceed, then stop. You need explicit
|
|
||||||
user approval to modify existing tests. (You do not need user approval
|
|
||||||
for writing NEW tests.)
|
|
||||||
|
|
||||||
* When linting, assume the linter config is CORRECT, and that each item
|
|
||||||
output by the linter is something that legitimately needs fixing in the
|
|
||||||
code.
|
|
||||||
|
|
||||||
* When running tests, use `make test`.
|
|
||||||
|
|
||||||
* Before commits, run `make check`. This runs `make lint` and `make test`
|
|
||||||
and `make check-fmt`. Any issues discovered MUST be resolved before
|
|
||||||
committing unless explicitly told otherwise.
|
|
||||||
|
|
||||||
* When fixing a bug, write a failing test for the bug FIRST. Add
|
|
||||||
appropriate logging to the test to ensure it is written correctly. Commit
|
|
||||||
that. Then go about fixing the bug until the test passes (without
|
|
||||||
modifying the test further). Then commit that.
|
|
||||||
|
|
||||||
* When adding a new feature, do the same - implement a test first (TDD). It
|
|
||||||
doesn't have to be super complex. Commit the test, then commit the
|
|
||||||
feature.
|
|
||||||
|
|
||||||
* When adding a new feature, use a feature branch. When the feature is
|
|
||||||
completely finished and the code is up to standards (passes `make check`)
|
|
||||||
then and only then can the feature branch be merged into `main` and the
|
|
||||||
branch deleted.
|
|
||||||
|
|
||||||
* Write godoc documentation comments for all exported types and functions as
|
|
||||||
you go along.
|
|
||||||
|
|
||||||
* ALWAYS be consistent in naming. If you name something one thing in one
|
|
||||||
place, name it the EXACT SAME THING in another place.
|
|
||||||
|
|
||||||
* Be descriptive and specific in naming. `wl` is bad;
|
|
||||||
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
|
||||||
`MaxConnectionsPerHost` is good.
|
|
||||||
|
|
||||||
* This is not prototype or teaching code - this is designed for production.
|
|
||||||
Any security issues (such as denial of service) or other web
|
|
||||||
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
|
||||||
|
|
||||||
* As this is production code, no stubbing of implementations unless
|
|
||||||
specifically instructed. We need working implementations.
|
|
||||||
|
|
||||||
* NEVER silently fall back to a different setting when a user's parameter
|
|
||||||
explicitly specifies a value. If a user requests format=webp and WebP
|
|
||||||
encoding is not supported, return an error - do NOT silently output PNG
|
|
||||||
instead. If a user specifies fit=invalid and that fit mode doesn't exist,
|
|
||||||
return an error - do NOT silently default to "cover". Silent fallbacks
|
|
||||||
violate the principle of least surprise and mask bugs. The only acceptable
|
|
||||||
defaults are for OMITTED parameters, never for INVALID explicit values.
|
|
||||||
|
|
||||||
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
|
||||||
the vendor directory, NEVER commit compiled binaries. If these
|
|
||||||
directories or files exist, add them to .gitignore (and commit the
|
|
||||||
.gitignore) if they are not already in there. Keep the entire git
|
|
||||||
repository (with history) small - under 20MiB, unless you specifically
|
|
||||||
must commit larger files (e.g. test fixture example media files). Only
|
|
||||||
OUR source code and immediately supporting files (such as test examples)
|
|
||||||
goes into the repo/history.
|
|
||||||
+97
-54
@@ -1,84 +1,127 @@
|
|||||||
# Lint stage
|
# In every stage ARG CACHEBUST comes right after the steps that install
|
||||||
# Same image as Dockerfile.lint: change both pins together.
|
# system packages and download Go's modules, or right after FROM in a
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
# stage with neither, and every step that lints, tests or compiles
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
# comes after it. script/ passes a new value on every build, which
|
||||||
|
# reruns every RUN step after it. Docker keeps the layers above it
|
||||||
|
# until the base image, their own lines or go.mod and go.sum change.
|
||||||
|
# The steps that compile mount Go's build cache, and golangci-lint run
|
||||||
|
# also mounts golangci-lint's; both survive from build to build.
|
||||||
|
|
||||||
|
# Lint phase. script/lint builds it alone. The linter is run directly:
|
||||||
|
# `make lint` and script/lint are themselves a docker build.
|
||||||
|
# golangci/golangci-lint:v2.14.0, 2026-10-15
|
||||||
|
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
|
||||||
|
# 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
|
WORKDIR /src
|
||||||
|
|
||||||
# script/bootstrap installs the build dependencies and downloads the Go
|
|
||||||
# modules. Only script/, go.mod and go.sum are copied first, so this
|
|
||||||
# layer is reused until one of them changes.
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
--mount=type=cache,target=/root/.cache/golangci-lint \
|
||||||
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Copy source code
|
# Test phase. script/test builds it alone. -race needs cgo and so a C
|
||||||
|
# compiler, which the Debian Go image ships and the alpine one does not.
|
||||||
|
# golang:1.25.4-trixie, 2026-10-15
|
||||||
|
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
|
||||||
|
|
||||||
|
# govips compiles against the libvips and libheif headers, and the tests
|
||||||
|
# need libvips' JPEG XL support; libvips-dev brings all three. The tests
|
||||||
|
# also encode AVIF, which needs libheif's AVIF encoder,
|
||||||
|
# libheif-plugin-aomenc: Debian only recommends it, so it is named here.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev libheif-plugin-aomenc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
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. -count=1 because the
|
||||||
|
# mounted cache also holds test results, which go test would otherwise
|
||||||
|
# replay. -parallel 4: by default a package runs as many of its tests at
|
||||||
|
# once as the host has CPUs, and on a busy host they then wait so long
|
||||||
|
# to be scheduled that a test that times a request can see it return a
|
||||||
|
# second late.
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
|
||||||
|
|
||||||
# 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 nothing after them runs unless lint
|
||||||
# command, so a new value reruns the step instead of reusing a cached
|
# and 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
|
# git for the version, and for govips a C compiler, pkg-config and the
|
||||||
# golang:1.25.4-alpine, 2026-02-25
|
# libvips and libheif headers
|
||||||
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
RUN apk add --no-cache git build-base pkgconfig vips-dev libheif-dev
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
# 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
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
# Build dependencies and Go modules, as in the lint stage
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run tests; a new CHECK_EPOCH reruns them, as in the lint stage.
|
# The VERSION build arg when one is given, otherwise
|
||||||
ARG CHECK_EPOCH
|
# `git describe --tags --always` on the .git in the build context: the
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
|
# tag on a tagged commit, tag-N-gHASH after one, the short commit when
|
||||||
|
# no tag is reachable. With .git present, a version that is still empty,
|
||||||
# VERSION is declared here, not earlier: a new value reruns only the
|
# dev or unknown fails the build: git is missing or could not read the
|
||||||
# build, not script/bootstrap or the tests. Given none, the version is
|
# checkout. A context without .git, as from a source tarball, stamps an
|
||||||
# `git describe --tags --always` of the .git in the build context (git
|
# empty version. CGO stays enabled for govips; -trimpath keeps build
|
||||||
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
|
# paths out of the binary, and -s -w leave out the symbol table and
|
||||||
# after one, the short commit when no tag is reachable. A context that
|
# debug information.
|
||||||
# carries .git and still yields no version fails the build; one without
|
|
||||||
# .git, as from a source tarball, stamps an empty version. CGO stays
|
|
||||||
# enabled for govips; -trimpath keeps build paths out of the binary, and
|
|
||||||
# -s -w leave out the symbol table and debug information.
|
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
[ "$version" = unknown ]; }; then \
|
if [ -e .git ]; then \
|
||||||
echo "the build context carries .git but yields no version" >&2; \
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
exit 1; \
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
||||||
-ldflags "-s -w -X main.Version=${version}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /pixad ./cmd/pixad
|
-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 \
|
||||||
su-exec
|
su-exec
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /pixad /usr/local/bin/pixad
|
COPY --from=builder /pixad /usr/local/bin/pixad
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
|
|
||||||
# which is never installed on the host. Pinned to the same image as the
|
|
||||||
# Dockerfile lint stage: change both pins together, or the two run
|
|
||||||
# different linter versions.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
|
||||||
# this image needs the same C libraries the build does. script/bootstrap
|
|
||||||
# installs them and downloads the Go modules. Only script/, go.mod and
|
|
||||||
# go.sum are copied first; they settle this layer's result, so it may
|
|
||||||
# safely be reused between runs.
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
|
||||||
ENV container=docker
|
|
||||||
|
|
||||||
# script/lint passes a different CACHEBUST on every run, and BuildKit
|
|
||||||
# keys every RUN after this ARG on its value, so the lint step always
|
|
||||||
# runs instead of returning a cached success that linted nothing.
|
|
||||||
#
|
|
||||||
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
|
|
||||||
# tmpfs that is discarded after the step. Written into the layer, they
|
|
||||||
# would pile up as build cache on every run, since no later run, with
|
|
||||||
# its new CACHEBUST, can reuse that layer.
|
|
||||||
ARG CACHEBUST
|
|
||||||
RUN --mount=type=tmpfs,target=/root/.cache script/lint
|
|
||||||
@@ -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 bench
|
||||||
|
|
||||||
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,31 @@ 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)
|
# Serve cache hits in parallel through the image handler and report hits per
|
||||||
|
# second (needs Go on the host after `script/bootstrap --cgo`, not Docker; a
|
||||||
|
# benchmark, not part of check)
|
||||||
|
bench:
|
||||||
|
@script/bench
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
+295
-109
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-09-08
|
last_modified: 2026-10-18
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -54,34 +54,39 @@ style conventions are in separate documents:
|
|||||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
(detected in that order; apt runs noninteractive). For node it uses the
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
installed node if present; otherwise it installs a PINNED node version via
|
installed node only when its major version is the pinned one; otherwise (node
|
||||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
missing, or another major, such as the node 24 the CI runner image ships) it
|
||||||
release archive (never `curl | sh`), with bash installed as an explicit
|
installs the PINNED node version via nvm and installs yarn and the packages
|
||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
under it. nvm itself is installed first if missing, from a hash-verified
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
GitHub release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
prerequisite since nvm requires bash. Only the major version is compared
|
||||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
because the `Dockerfile` stages start from a node image whose exact version is
|
||||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
|
||||||
|
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
|
||||||
|
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
|
||||||
|
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
|
||||||
|
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
pristine checkout with nothing installed the run dies there, after the
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
containerised gates have passed. **The bootstrap alone is not enough**: when
|
||||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
|
||||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
|
||||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
under another node. The host entrypoints that need yarn — `script/fmt` and
|
||||||
source nvm for the pinned node version before invoking it, exactly as
|
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
|
||||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
|
||||||
docker and git then gets through `script/check`. Four further scripts are our
|
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
|
||||||
own extensions to the standard: `script/check` runs `script/test`,
|
pinned node. A runner carrying nothing but docker and git then gets through
|
||||||
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
`script/check`. Four further scripts are our own extensions to the standard:
|
||||||
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
|
||||||
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
`script/precommit` is what the git pre-commit hook runs, and it calls
|
||||||
`script/projectname` (literally that filename) simply outputs the project's
|
`script/check`; `script/install-precommit` installs the git pre-commit hook
|
||||||
name. Scripts that need the name call `script/projectname` — e.g.
|
(the `make hooks` target shims to it); and `script/projectname` (literally
|
||||||
`script/docker` assembles its image tag from it — so those scripts stay
|
that filename) simply outputs the project's name. Scripts that need the name
|
||||||
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
call `script/projectname` — e.g. `script/docker` assembles its image tag from
|
||||||
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
it — so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
the hook itself. Model scripts are at
|
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).
|
||||||
@@ -104,18 +109,24 @@ style conventions are in separate documents:
|
|||||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
image cannot be built unless they pass. For non-server repos the final stage
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
brings up a development environment; for server repos it is the runtime image.
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
Dockerfiles install development prerequisites by running `script/bootstrap`
|
The gate phases and the build stage start from their pinned base images and
|
||||||
rather than duplicating installs inline; COPY `script/` and the dependency
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
running it.
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
|
environment stage installs development prerequisites by running
|
||||||
|
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||||
|
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||||
|
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||||
|
|
||||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
and nothing else:
|
and nothing else:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||||
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
--target lint --output type=cacheonly .
|
||||||
|
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||||
|
--target test --output type=cacheonly .
|
||||||
```
|
```
|
||||||
|
|
||||||
**A stage that is not the last one in the file is built only when the final
|
**A stage that is not the last one in the file is built only when the final
|
||||||
@@ -125,10 +136,15 @@ style conventions are in separate documents:
|
|||||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
and tested nothing.
|
and tested nothing.
|
||||||
|
|
||||||
**Every `docker build` in `script/` is tagged**, here and in
|
**The gate builds write no image.** With `--output type=cacheonly` the phase
|
||||||
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
runs and a failing step fails the build, but the result is not exported.
|
||||||
image behind on every invocation, on every developer host and every CI
|
Nothing uses those images, and writing one out is slow: a Go test phase's
|
||||||
runner; a tagged one replaces the previous image.
|
image holds the toolchain. A build given neither `--output` nor `-t` writes
|
||||||
|
an untagged image and leaves it dangling, on every developer host and every
|
||||||
|
CI runner. `script/cibuild` and `script/docker` build the image that ships
|
||||||
|
and tag it, so each build replaces the previous image; each assigns the tag
|
||||||
|
on its own line before the build, so `set -e` stops it where
|
||||||
|
`script/projectname` fails.
|
||||||
|
|
||||||
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
@@ -143,66 +159,122 @@ style conventions are in separate documents:
|
|||||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
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
|
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
|
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
|
org, in both directions. A container has its own `TMPDIR`, so the lock is
|
||||||
a digest-pinned binary, so neither is reachable.
|
out of reach. The lint phase does keep golangci-lint's cache between check
|
||||||
|
builds (below), but it lints every checkout at `/src`, and a finding is
|
||||||
|
taken from that cache only for the same files, settings and golangci-lint
|
||||||
|
version.
|
||||||
|
|
||||||
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
||||||
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
invalidates a `COPY` layer only when the copied content changes, so on an
|
||||||
the check `RUN` is served from cache, nothing executes, and the build still
|
unchanged tree the check `RUN` is served from cache, nothing executes, and the
|
||||||
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
build still exits 0. Every `docker build` in `script/` therefore passes
|
||||||
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
|
||||||
four, and there is no fifth — `script/check` runs the two gate phases and
|
id, so no two builds share a value, not even the three in one
|
||||||
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||||
not evidence that anything ran: a sub-second build reporting success is a
|
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
||||||
and friends destroy a build cache shared with every other build on the host.
|
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
|
||||||
|
that install system packages and download Go's modules, or right after `FROM`
|
||||||
|
in a stage with neither, and every step that lints, tests or compiles comes
|
||||||
|
after it. A step that installs anything else as well, such as a
|
||||||
|
`script/bootstrap` that also installs node packages, comes after it too. A new
|
||||||
|
value reruns every `RUN` step after that line; `COPY` steps are compared by
|
||||||
|
content and need nothing. Docker keeps the layers above the line until the
|
||||||
|
base image, their own lines or the files they copy change. A stage without the
|
||||||
|
`ARG` line is served from cache on an unchanged tree, so a repository
|
||||||
|
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
|
||||||
|
not pass `--no-cache`: it reruns every step too, but it also builds the layers
|
||||||
|
above the line again and empties every cache mount, so Go's build cache and
|
||||||
|
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
|
||||||
|
survive between check builds, would start empty every time. A bare
|
||||||
|
`docker build .` is not evidence that anything ran: a sub-second build
|
||||||
|
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
||||||
|
`docker builder prune` and friends destroy a build cache shared with every
|
||||||
|
other build on the host. When a check is added or changed, prove it works by
|
||||||
|
planting a defect it must catch and watching the run fail on it, then revert
|
||||||
|
the defect. A green run alone shows neither that the check ran nor that it
|
||||||
|
covers what it should.
|
||||||
|
|
||||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
- **The 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
|
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,
|
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
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
`Dockerfile`:
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
|
# In every stage ARG CACHEBUST comes right after the steps that install
|
||||||
|
# system packages and download Go's modules, or right after FROM in a
|
||||||
|
# stage with neither, and every step that lints, tests or compiles
|
||||||
|
# comes after it. script/ passes a new value on every build, which
|
||||||
|
# reruns every RUN step after it. Docker keeps the layers above it
|
||||||
|
# until the base image, their own lines or go.mod and go.sum change.
|
||||||
|
# The steps that compile mount Go's build cache, and golangci-lint run
|
||||||
|
# also mounts golangci-lint's; both survive from build to build.
|
||||||
|
|
||||||
# Lint phase
|
# 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
|
||||||
|
ARG CACHEBUST
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
--mount=type=cache,target=/root/.cache/golangci-lint \
|
||||||
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# image ships and the alpine one does not. -count=1 because the mounted
|
||||||
|
# cache also holds test results, which go test would otherwise replay.
|
||||||
|
# golang:1.x, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS test
|
FROM golang@sha256:... AS test
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go test -timeout 90s -race -cover ./... || \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from either phase above; the copies
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
# are what make BuildKit build them first, so this stage cannot run
|
# are what make BuildKit build them first, so nothing after them can
|
||||||
# unless lint and test passed.
|
# run unless lint and test passed.
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# 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
|
RUN apk add --no-cache git
|
||||||
COPY --from=test /src/go.sum /dev/null
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VERSION=dev
|
# The VERSION build arg when one is given, otherwise
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
# `git describe --tags --always` on the .git in the build context. With
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
-o /app ./cmd/app/
|
# build: git is missing or could not read the checkout.
|
||||||
|
ARG VERSION
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi; \
|
||||||
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
# Runtime stage, and the last one
|
# Runtime stage, and the last one
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
|
ARG CACHEBUST
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
@@ -217,23 +289,106 @@ style conventions are in separate documents:
|
|||||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
- 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
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
and nothing else.
|
and nothing else.
|
||||||
|
- Check builds keep these, and none of them can hand back a stale result:
|
||||||
|
- The layers above `ARG CACHEBUST`: the system packages and Go's
|
||||||
|
modules. Docker installs the packages again when the base image or the
|
||||||
|
install line changes, and downloads the modules again when `go.mod` or
|
||||||
|
`go.sum` changes. Go checks every module against `go.sum` as it
|
||||||
|
downloads it, and no later build can change a kept layer. The modules
|
||||||
|
are kept in a layer rather than a cache mount because a cache mount is
|
||||||
|
shared by every build on the host, and Go builds from whatever files
|
||||||
|
it holds without checking them again (only `go mod verify` does), so a
|
||||||
|
file changed there would go unnoticed.
|
||||||
|
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
|
||||||
|
root), mounted on the steps that compile: `golangci-lint run`, both
|
||||||
|
`go test` runs and `go build`. Go reuses a compiled package only when
|
||||||
|
all its inputs are unchanged. The cache also holds test results, which
|
||||||
|
is why both `go test` runs carry `-count=1`.
|
||||||
|
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
|
||||||
|
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
|
||||||
|
a package's findings only when the package's files and those of every
|
||||||
|
package it imports, `go.mod`, the linters and their settings in
|
||||||
|
`.golangci.yml`, and the golangci-lint version are all unchanged.
|
||||||
- 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 phase 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`.
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
`vips-dev`), install them in the lint phase with `apk add`.
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
`script/docker` and `script/cibuild`; no stage may call `git describe`.
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
|
lists in the same `RUN`, so the layer does not keep them. The test phase,
|
||||||
|
on the Debian Go image, installs the libraries its tests need the same
|
||||||
|
way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so
|
||||||
|
Docker keeps it until the base image or the install line changes:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
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
|
||||||
|
ARG CACHEBUST
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||||
|
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||||
|
submodule keeping its own `.git` directory. `git describe` does not need
|
||||||
|
them, and each can hold a credential: a password in a remote URL, or the
|
||||||
|
token the CI checkout step stores there. A submodule whose name has a
|
||||||
|
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||||
|
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||||
|
then fails the build: give it a name without that segment
|
||||||
|
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||||
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
A checkout whose `.git` is a file (a linked worktree, or a repository
|
||||||
|
checked out as a submodule) is the exception: that file points to a git
|
||||||
|
directory outside the build context, so the build cannot read the version
|
||||||
|
and a plain `docker build .` fails; pass the version with
|
||||||
|
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
||||||
|
do.
|
||||||
|
|
||||||
- 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` on push, and checks out the repo as its only other step.
|
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
||||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||||
successful run means every check passed; a bare `docker build .` does not
|
without it the checkout leaves the job's token in `.git/config` for every
|
||||||
|
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
|
||||||
|
tags `git describe` needs: by default it clones shallow with no tags, and a
|
||||||
|
tagged repository's CI build would stamp a bare short commit id. The
|
||||||
|
workflow's `concurrency` block groups runs by workflow and branch
|
||||||
|
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
|
||||||
|
so a new push cancels the older run on the same branch, queued or running, and
|
||||||
|
no other: runs for replaced commits do not hold up the shared runner.
|
||||||
|
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
|
||||||
|
so a successful run means every check passed; a bare `docker build .` does not
|
||||||
carry the same guarantee, because its gate phases may come from the cache. The
|
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
|
image build gets a `CACHEBUST` value of its own and so runs the gate phases a
|
||||||
price of the rule above, and it is worth paying: the image that ships is built
|
second time. That is the price of the rule above, and it is worth paying: the
|
||||||
from a run of its own gates rather than from a cache entry.
|
image that ships is built from a run of its own gates rather than from a cache
|
||||||
|
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
|
||||||
|
shared runner after 20 minutes. That allows for the three Docker builds
|
||||||
|
described above (the test phase, the lint phase, then the image), each held to
|
||||||
|
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
|
||||||
|
limited to `main` by a `branches` list under `on: push` cannot be checked by
|
||||||
|
review: to try a change to it, add the feature branch to that list and push,
|
||||||
|
then remove the branch from the list again before merging. Keep any job in it
|
||||||
|
that publishes behind `if: github.ref_name == 'main'`, so the run from the
|
||||||
|
feature branch publishes nothing.
|
||||||
|
|
||||||
- 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
|
||||||
@@ -286,17 +441,18 @@ style conventions are in separate documents:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`-count=1` is required on both invocations: it defeats Go's test _result_
|
`-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
|
cache, so neither run can report a stored pass in place of running the
|
||||||
reproduces a failure instead of replaying it. It leaves the build cache
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
alone, so it costs the runtime of the suite and no recompilation.
|
and no recompilation.
|
||||||
|
|
||||||
Note that this is a second, independent cache, stacked below the Docker
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
it does not guarantee `go test` inside that step does any work, because the
|
without running them. The directory lasts from one run to the next on a
|
||||||
`GOCACHE` baked into earlier image layers survives into the re-executed
|
developer's machine, where this target runs, and in the `test` phase of the
|
||||||
step. They are two separate defects requiring two separate fixes, and a fix
|
`Dockerfile`, which mounts it to keep compiled packages between check
|
||||||
for one must not be recorded as covering the other.
|
builds, so the phase's `go test` runs carry `-count=1` too. A new
|
||||||
|
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
@@ -324,9 +480,12 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- `.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`, `*~`), in-repo agent scratch directories (`.claude/`),
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
`node_modules/`, and the repo's own build outputs. Fetch the standard
|
||||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
`.gitignore` from
|
||||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
|
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
|
||||||
|
own entries, such as its binaries; a re-vendor replaces the standard part and
|
||||||
|
keeps those entries. These patterns are written to `.gitignore`'s own
|
||||||
semantics, in which an unanchored pattern already matches at every depth; they
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
@@ -340,7 +499,7 @@ style conventions are in separate documents:
|
|||||||
— which is more dangerous than a short file with no secret patterns at all,
|
— 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
|
because it reads as solved and stops anyone looking. Give every
|
||||||
depth-independent pattern the `**/` prefix and leave only genuinely
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
root-anchored entries unprefixed: `.git`, and the repo's own host-built
|
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||||
binary, written `/myapp` and never `**/myapp`, which would also match
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
`cmd/myapp/` and delete the package directory from the context. Matching is
|
`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
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
@@ -365,21 +524,24 @@ style conventions are in separate documents:
|
|||||||
directory, so a repo running agents in subdirectories still ships
|
directory, so a repo running agents in subdirectories still ships
|
||||||
`services/api/.claude/` and must add its own anchored entry there.
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
|
- **A plain `docker build .` of a clone stamps the version that
|
||||||
it fails quietly there.** In a build stage there is no repository, so
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||||
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||||
the binary reports no version at all, and the build still exits 0. Compute the
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||||
version on the host and thread it in as a build arg. `script/docker` and
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||||
`script/cibuild` do this, byte-identically across repos:
|
the version they compute on the host; it takes precedence. They do this
|
||||||
|
byte-identically across repos:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Own line: a failing command substitution inside an argument does not
|
# The version and the tag each get their own line: a failing command
|
||||||
# trip `set -e`, so the inline form degrades to an empty constant.
|
# 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)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
docker build --no-cache \
|
tag="$(script/projectname)"
|
||||||
|
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||||
--build-arg VERSION="$version" \
|
--build-arg VERSION="$version" \
|
||||||
-t "$(script/projectname)" .
|
-t "$tag" .
|
||||||
```
|
```
|
||||||
|
|
||||||
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
@@ -387,12 +549,12 @@ style conventions are in separate documents:
|
|||||||
fallback is applied — a live check that fires on a build from an export with
|
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
|
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
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
|
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||||
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
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
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
the scripts stay byte-identical. One consequence for CI: the standard
|
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
|
checkout action clones shallow and fetches no tags, so the canonical
|
||||||
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
patterns.** Plant files at the root _and_ at least two directories deep, build
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
@@ -426,12 +588,18 @@ style conventions are in separate documents:
|
|||||||
`test-support` depguard rule, where a repo names its own test-support packages
|
`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
|
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
|
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
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
image
|
image
|
||||||
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
only pin, since no repo installs golangci-lint on the host: bumping the
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
version means changing it and nothing else.
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
@@ -453,8 +621,18 @@ style conventions are in separate documents:
|
|||||||
function defined and never invoked has the same exit status and the same
|
function defined and never invoked has the same exit status and the same
|
||||||
empty output as one that worked.
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Node is the exception to the whole-token comparison: the canonical
|
||||||
|
`script/bootstrap` compares only its major version, because the node 22
|
||||||
|
alpine image the `Dockerfile` stages start from is not the exact pin, and
|
||||||
|
nvm cannot install a prebuilt node on alpine.
|
||||||
|
|
||||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
|
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||||
|
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||||
|
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||||
|
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||||
|
|
||||||
- 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).
|
||||||
|
|
||||||
@@ -564,13 +742,17 @@ style conventions are in separate documents:
|
|||||||
Never edit existing migrations after release.
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings: the standard file from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
|
||||||
|
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
|
||||||
|
one for another language it uses. A re-vendor replaces the standard part and
|
||||||
|
keeps those sections.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
subdirectory names:
|
Canonical subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
- `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
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
@@ -601,3 +783,7 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
|
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||||
|
is never committed under a file or directory named after one agent tool, such
|
||||||
|
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-10
@@ -45,15 +45,14 @@ state_dir: ./data
|
|||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
# Hosts that don't require signatures (default: none)
|
# Hosts that don't require signatures (default: none)
|
||||||
|
# Every image on an allowlisted host is served to anyone without a
|
||||||
|
# signature, at any size and in any format, so list only hosts you
|
||||||
|
# control, where nobody else can put images.
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
# 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
|
# 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.
|
# brackets), such as one with a port or a "*." wildcard, aborts startup.
|
||||||
allowlist_hosts:
|
# allowlist_hosts:
|
||||||
- s3.sneak.cloud
|
# - images.example.com
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- github.com
|
|
||||||
- user-images.githubusercontent.com
|
|
||||||
|
|
||||||
# Hosts whose pages may not show pixa's images, written as for
|
# 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
|
# allowlist_hosts. A request to /v1/image/ or /v1/e/ whose Referer header
|
||||||
@@ -109,12 +108,21 @@ upstream_connections_per_host: 20
|
|||||||
# downstream_timeout has ended first.
|
# downstream_timeout has ended first.
|
||||||
upstream_connections: 64
|
upstream_connections: 64
|
||||||
|
|
||||||
# Maximum number of images decoded and encoded at once (default: the
|
# Maximum number of images decoded and encoded at once (default: half the
|
||||||
# number of CPUs pixa can use, which follows a container's CPU limit). A
|
# number of CPUs pixa can use, at least 1; the number of CPUs follows a
|
||||||
# request that finds none free waits up to 10 seconds for one, and if none
|
# container's CPU limit). A request that finds none free waits up to 10
|
||||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
# seconds for one, and if none 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
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ require (
|
|||||||
github.com/go-chi/httprate v0.16.0
|
github.com/go-chi/httprate v0.16.0
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
|||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -88,3 +88,56 @@ func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksMappedEntryIsIPv4Range checks that an entry written in
|
||||||
|
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
|
||||||
|
// since the fetcher matches an IPv4-mapped address as the IPv4 address it
|
||||||
|
// holds. /96, the shortest such entry, covers every IPv4 address.
|
||||||
|
func TestBlockedNetworksMappedEntryIsIPv4Range(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `blocked_networks:
|
||||||
|
- "::ffff:10.0.0.0/104"
|
||||||
|
- "::ffff:0:0/96"
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IPv4-mapped blocked_networks entries should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"10.0.0.0/8", "0.0.0.0/0"}
|
||||||
|
if len(c.BlockedNetworks) != len(want) {
|
||||||
|
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, w := range want {
|
||||||
|
if got := c.BlockedNetworks[i].String(); got != w {
|
||||||
|
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksMappedEntryShorterThan96AbortsStartup checks that an
|
||||||
|
// entry in the IPv4-mapped form shorter than /96, which covers more than the
|
||||||
|
// IPv4 addresses, aborts startup with an error naming the key and the value.
|
||||||
|
func TestBlockedNetworksMappedEntryShorterThan96AbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "mapped-shorter-than-96",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- "::ffff:0.0.0.0/80"
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "::ffff:0.0.0.0/80"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mapped-95",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- "::ffff:0:0/95"
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -12,8 +13,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
||||||
// max_concurrent_processing is the number of CPUs Go uses and an omitted
|
// max_concurrent_processing is half the CPUs Go uses, and at least 1, and
|
||||||
// upstream_connections is 64.
|
// an omitted upstream_connections is 64.
|
||||||
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -22,9 +23,9 @@ func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
|||||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.MaxConcurrentProcessing != runtime.GOMAXPROCS(0) {
|
if c.MaxConcurrentProcessing != max(runtime.GOMAXPROCS(0)/2, 1) {
|
||||||
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
|
t.Errorf("MaxConcurrentProcessing = %d, want %d, half the CPUs",
|
||||||
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
|
c.MaxConcurrentProcessing, max(runtime.GOMAXPROCS(0)/2, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.UpstreamConnections != 64 {
|
if c.UpstreamConnections != 64 {
|
||||||
@@ -32,6 +33,36 @@ func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOmittedMaxConcurrentProcessingIsHalfTheCPUsAndAtLeastOne checks
|
||||||
|
// that an omitted max_concurrent_processing is half the CPUs Go uses,
|
||||||
|
// rounded down, and 1 when Go uses one CPU.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
|
||||||
|
func TestOmittedMaxConcurrentProcessingIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
|
||||||
|
cpusBefore := runtime.GOMAXPROCS(0)
|
||||||
|
|
||||||
|
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
|
||||||
|
|
||||||
|
for _, tc := range []struct{ cpus, want int }{
|
||||||
|
{1, 1}, {2, 1}, {3, 1}, {8, 4},
|
||||||
|
} {
|
||||||
|
runtime.GOMAXPROCS(tc.cpus)
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GOMAXPROCS %d: MaxConcurrentProcessing %d",
|
||||||
|
tc.cpus, c.MaxConcurrentProcessing)
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != tc.want {
|
||||||
|
t.Errorf("GOMAXPROCS %d: MaxConcurrentProcessing = %d, want %d",
|
||||||
|
tc.cpus, c.MaxConcurrentProcessing, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
||||||
// two limits are used as given.
|
// two limits are used as given.
|
||||||
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
||||||
@@ -160,3 +191,19 @@ func TestConcurrencyLimitsFromEnvironment(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInvalidMaxConcurrentProcessingFromEnvironmentAbortsStartup checks
|
||||||
|
// that a PIXA_MAX_CONCURRENT_PROCESSING that is not a whole number of at
|
||||||
|
// least 1, an empty one included, aborts startup naming the variable and
|
||||||
|
// the key instead of taking the default.
|
||||||
|
func TestInvalidMaxConcurrentProcessingFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
for _, value := range []string{"", " ", "0.5", "half", "-1", "4 images"} {
|
||||||
|
t.Run(fmt.Sprintf("value %q", value), func(t *testing.T) {
|
||||||
|
t.Setenv(testMaxConcurrentProcessingVar, value)
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err,
|
||||||
|
testMaxConcurrentProcessingVar, keyMaxConcurrentProcessing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+96
-15
@@ -34,8 +34,17 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultMaxConcurrentProcessing returns the most images processed at once
|
||||||
|
// when max_concurrent_processing is omitted: half the CPUs Go uses
|
||||||
|
// (runtime.GOMAXPROCS(0), which follows a container's CPU limit), rounded
|
||||||
|
// down, and at least 1.
|
||||||
|
func DefaultMaxConcurrentProcessing() int {
|
||||||
|
return max(runtime.GOMAXPROCS(0)/2, 1) //nolint:mnd // half the CPUs
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
const (
|
const (
|
||||||
keyDebug = "debug"
|
keyDebug = "debug"
|
||||||
@@ -61,6 +70,7 @@ 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
|
||||||
@@ -81,6 +91,7 @@ var (
|
|||||||
errNotABoolean = errors.New("not a boolean")
|
errNotABoolean = errors.New("not a boolean")
|
||||||
errNotAStringList = errors.New("not a list of strings")
|
errNotAStringList = errors.New("not a list of strings")
|
||||||
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
||||||
|
errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96")
|
||||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||||
errEmptyEntry = errors.New("contains an empty entry")
|
errEmptyEntry = errors.New("contains an empty entry")
|
||||||
@@ -158,6 +169,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.
|
||||||
@@ -290,10 +306,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
UpstreamConnections: loader.intVal(
|
UpstreamConnections: loader.intVal(
|
||||||
keyUpstreamConnections, DefaultUpstreamConnections),
|
keyUpstreamConnections, DefaultUpstreamConnections),
|
||||||
// Decoding and encoding are CPU-bound, so the default is one image
|
|
||||||
// per CPU Go uses, which follows a container's CPU limit.
|
|
||||||
MaxConcurrentProcessing: loader.intVal(
|
MaxConcurrentProcessing: loader.intVal(
|
||||||
keyMaxConcurrentProcessing, runtime.GOMAXPROCS(0)),
|
keyMaxConcurrentProcessing, DefaultMaxConcurrentProcessing()),
|
||||||
UpstreamFetchTimeout: loader.durationVal(
|
UpstreamFetchTimeout: loader.durationVal(
|
||||||
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
||||||
UpstreamMaxResponseSize: loader.int64Val(
|
UpstreamMaxResponseSize: loader.int64Val(
|
||||||
@@ -302,6 +316,8 @@ 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,
|
||||||
@@ -311,9 +327,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
// The default for an omitted cache_max_bytes is worked out when
|
// The default for an omitted cache_max_bytes is worked out when
|
||||||
// the cache opens; here we only record whether the operator set
|
// the cache opens; here 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
|
||||||
@@ -325,8 +339,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
settingName(keyDBURL), errValueEmpty)
|
settingName(keyDBURL), errValueEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The driver sets the journal mode only through a _pragma parameter.
|
c.DBURL = defaultDBURL(c.StateDir)
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if loader.err != nil {
|
if loader.err != nil {
|
||||||
@@ -341,6 +354,30 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultDBURL returns the db_url used when db_url is omitted: the file
|
||||||
|
// state.sqlite3 in stateDir, in WAL mode, which the driver sets only
|
||||||
|
// through a _pragma parameter. String() percent-encodes any %, ? or # in
|
||||||
|
// the directory name, which SQLite decodes back, so the URL names exactly
|
||||||
|
// this file. OmitHost writes file:/var/lib/pixa/... instead of
|
||||||
|
// file:///var/lib/pixa/..., and keeps a relative stateDir from being read
|
||||||
|
// as a host name. The path written into the URL is stateDir followed by
|
||||||
|
// /state.sqlite3; when it starts with //, as it does for a stateDir of /
|
||||||
|
// or one starting with //, the empty host is kept, so the URL starts
|
||||||
|
// file://// and SQLite does not read the first name in the path as a
|
||||||
|
// host.
|
||||||
|
func defaultDBURL(stateDir string) string {
|
||||||
|
dbPath := stateDir + "/state.sqlite3"
|
||||||
|
|
||||||
|
dbURL := url.URL{
|
||||||
|
Scheme: "file",
|
||||||
|
OmitHost: !strings.HasPrefix(dbPath, "//"),
|
||||||
|
Path: dbPath,
|
||||||
|
RawQuery: "_pragma=journal_mode(WAL)",
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbURL.String()
|
||||||
|
}
|
||||||
|
|
||||||
// validateKnownKeys rejects configuration files containing keys the
|
// validateKnownKeys rejects configuration files containing keys the
|
||||||
// application does not understand, so typos fail at startup instead of
|
// application does not understand, so typos fail at startup instead of
|
||||||
// being silently ignored, and rejects keys that are explicitly set to
|
// being silently ignored, and rejects keys that are explicitly set to
|
||||||
@@ -426,7 +463,7 @@ func isKnownConfigKey(key string) bool {
|
|||||||
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
||||||
"env":
|
keyImageRequestsPerMinute, "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +498,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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +651,14 @@ 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
|
||||||
@@ -625,12 +671,9 @@ func (c *Config) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 == "") {
|
||||||
@@ -642,6 +685,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
|
||||||
@@ -794,6 +853,7 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
// one whose path runs through a file, such as under a HOME of
|
// 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
|
// /dev/null. One that cannot be read or does not parse is a
|
||||||
// fatal startup error.
|
// fatal startup error.
|
||||||
|
//nolint:gosec // G703: a fixed config location; HOME is the operator's
|
||||||
_, statErr := os.Stat(cleanPath)
|
_, statErr := os.Stat(cleanPath)
|
||||||
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||||
continue
|
continue
|
||||||
@@ -1189,6 +1249,13 @@ func defaultTrustedProxies() []netip.Prefix {
|
|||||||
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
||||||
// aborts startup naming the key and the offending value; the default
|
// aborts startup naming the key and the offending value; the default
|
||||||
// (an empty list) applies only to an omitted key.
|
// (an empty list) applies only to an omitted key.
|
||||||
|
//
|
||||||
|
// An entry written in the IPv4-mapped form (::ffff:a.b.c.d) is kept as the
|
||||||
|
// IPv4 range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. Both
|
||||||
|
// blocked_networks and trusted_proxies are matched against an IPv4-mapped
|
||||||
|
// address as the IPv4 address it holds, so the entry as written would match
|
||||||
|
// nothing. Such an entry shorter than /96 covers more than the IPv4
|
||||||
|
// addresses and aborts startup.
|
||||||
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||||
raw, ok := lookupValue(sc, key)
|
raw, ok := lookupValue(sc, key)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1204,6 +1271,10 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by
|
||||||
|
// the 32 bits of the IPv4 address it holds.
|
||||||
|
const mappedPrefixBits = 96
|
||||||
|
|
||||||
prefixes := make([]netip.Prefix, 0, len(entries))
|
prefixes := make([]netip.Prefix, 0, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -1213,6 +1284,16 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
settingName(key), entry, errNotAValidCIDR)
|
settingName(key), entry, errNotAValidCIDR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if prefix.Addr().Is4In6() && prefix.Bits() < mappedPrefixBits {
|
||||||
|
return nil, fmt.Errorf("%s: value %q is %w",
|
||||||
|
settingName(key), entry, errMappedTooShort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefix.Addr().Is4In6() {
|
||||||
|
prefix = netip.PrefixFrom(prefix.Addr().Unmap(),
|
||||||
|
prefix.Bits()-mappedPrefixBits)
|
||||||
|
}
|
||||||
|
|
||||||
prefixes = append(prefixes, prefix)
|
prefixes = append(prefixes, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,32 @@ func TestGetStringSlice_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
||||||
|
// with a real signing key, as Getting Started in README.md has it copied
|
||||||
|
// and edited, and checks that it allowlists no host: every image on an
|
||||||
|
// allowlisted host is served to anyone without a signature.
|
||||||
|
func TestExampleConfigAllowlistsNoHost(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
|
|
||||||
|
examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(examplePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load %s: %v", examplePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := newFromSmartConfig(sc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s is not a valid config: %v", examplePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("allowlist_hosts in %s: %v", examplePath, c.AllowlistHosts)
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 0 {
|
||||||
|
t.Errorf("%s allowlists %v, want no host", examplePath, c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// loadTestConfig is a helper to load a config file for testing.
|
// loadTestConfig is a helper to load a config file for testing.
|
||||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||||
return smartconfig.NewFromConfigPath(path)
|
return smartconfig.NewFromConfigPath(path)
|
||||||
|
|||||||
@@ -135,6 +135,104 @@ func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDerivedDBURLOpensStateDirHoldingURLCharacters opens the db_url
|
||||||
|
// derived from a state_dir whose name holds %41, ? and #, and checks that
|
||||||
|
// the database is state.sqlite3 in that directory, in WAL mode. Put into
|
||||||
|
// a file: URL as it is, the name would lose its meaning: %41 is read as
|
||||||
|
// A, and the path ends at the ?, which also loses the journal mode.
|
||||||
|
func TestDerivedDBURLOpensStateDirHoldingURLCharacters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(t.TempDir(), "a%41b?c#d")
|
||||||
|
|
||||||
|
err := os.Mkdir(stateDir, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %q: %v", stateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quoted so YAML takes the whole name as it is.
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: '"+stateDir+"'\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with state_dir %q should be valid, got: %v", stateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("state_dir %q gives db_url %q", stateDir, c.DBURL)
|
||||||
|
|
||||||
|
checkDBURLOpensFileInWALMode(t, c.DBURL, filepath.Join(stateDir, "state.sqlite3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDerivedDBURLOpensStateDirStartingWithTwoSlashes opens the db_url
|
||||||
|
// derived from a state_dir that starts with //, and checks that the
|
||||||
|
// database is state.sqlite3 in that directory, in WAL mode. Written right
|
||||||
|
// after file:, such a name gives a URL starting file://, and SQLite reads
|
||||||
|
// the first directory name as a host and refuses to open it.
|
||||||
|
func TestDerivedDBURLOpensStateDirStartingWithTwoSlashes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
stateDir := "/" + dir
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+stateDir+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with state_dir %q should be valid, got: %v", stateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("state_dir %q gives db_url %q", stateDir, c.DBURL)
|
||||||
|
|
||||||
|
checkDBURLOpensFileInWALMode(t, c.DBURL, filepath.Join(dir, "state.sqlite3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDerivedDBURLForRootStateDir checks the db_url derived from a
|
||||||
|
// state_dir of /. The path written into the URL is then
|
||||||
|
// //state.sqlite3, and written right after file: it gives a URL starting
|
||||||
|
// file://, where SQLite reads state.sqlite3 as a host and refuses to open
|
||||||
|
// it. The test only checks the URL, since it cannot write to /.
|
||||||
|
func TestDerivedDBURLForRootStateDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: /\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with state_dir / should be valid, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("state_dir / gives db_url %q", c.DBURL)
|
||||||
|
|
||||||
|
wantDBURL := "file:////state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
|
if c.DBURL != wantDBURL {
|
||||||
|
t.Errorf("DBURL = %q, want %q", c.DBURL, wantDBURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDBURLOpensFileInWALMode opens dbURL with the SQLite driver pixad
|
||||||
|
// uses, and checks that the database is in WAL mode and is the file
|
||||||
|
// dbFile.
|
||||||
|
func checkDBURLOpensFileInWALMode(t *testing.T, dbURL, dbFile string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open %q: %v", 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", dbURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalMode != "wal" {
|
||||||
|
t.Errorf("journal mode of %q = %q, want wal", dbURL, journalMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(dbFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("opening %q did not create %q: %v", dbURL, dbFile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
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")
|
||||||
@@ -99,6 +100,7 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
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")},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,3 +83,41 @@ func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTrustedProxiesMappedEntryIsIPv4Range checks that an entry written in
|
||||||
|
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
|
||||||
|
// since the client address is matched as the IPv4 address it holds. /96, the
|
||||||
|
// shortest such entry, covers every IPv4 address.
|
||||||
|
func TestTrustedProxiesMappedEntryIsIPv4Range(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
`trusted_proxies: ["::ffff:10.0.0.0/104", "::ffff:0:0/96"]`+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IPv4-mapped trusted_proxies entries should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(c.TrustedProxies))
|
||||||
|
for i, p := range c.TrustedProxies {
|
||||||
|
got[i] = p.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,0.0.0.0/0" {
|
||||||
|
t.Errorf("TrustedProxies = %v, want 10.0.0.0/8 and 0.0.0.0/0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrustedProxiesMappedEntryShorterThan96AbortsStartup checks that an
|
||||||
|
// entry in the IPv4-mapped form shorter than /96, which covers more than the
|
||||||
|
// IPv4 addresses, aborts startup with an error naming the key and the value.
|
||||||
|
func TestTrustedProxiesMappedEntryShorterThan96AbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "mapped-95",
|
||||||
|
yaml: signingKeyLine + `trusted_proxies: ["::ffff:0:0/95"]` + "\n",
|
||||||
|
wantErrSubstrings: []string{keyTrustedProxies, "::ffff:0:0/95"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestConcurrentWritesAllSucceed opens a database the way pixad does and
|
// TestConcurrentWritesAllSucceed opens a database the way pixad does and
|
||||||
// writes to it from several goroutines at once, so the writes run on
|
// writes to it from several goroutines at once, as one request's writes and
|
||||||
// separate connections, as one request's writes and the background eviction
|
// the background eviction pass do. Every write must succeed, none failing
|
||||||
// pass do. Every write must succeed, none failing with "database is locked",
|
// with "database is locked", whether or not db_url already has parameters,
|
||||||
// whether or not db_url already has parameters, and the parameters it has
|
// and the parameters it has must still apply.
|
||||||
// must still apply.
|
|
||||||
func TestConcurrentWritesAllSucceed(t *testing.T) {
|
func TestConcurrentWritesAllSucceed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -132,13 +131,13 @@ func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
|
(source_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storing a source: %w", err)
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
||||||
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
|
(cache_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("recording a transformed image: %w", err)
|
return fmt.Errorf("recording a transformed image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,17 +237,18 @@ 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 {
|
||||||
// Requests and the eviction pass write on separate connections. With
|
// With a busy timeout, a write that finds another program writing to
|
||||||
// a busy timeout, a write that finds another one in progress waits up
|
// the same database file waits up to five seconds for it instead of
|
||||||
// to five seconds for it instead of failing at once with "database is
|
// failing at once with "database is locked". The driver runs each
|
||||||
// locked". The driver runs each _pragma parameter on every connection
|
// _pragma parameter on every connection it opens.
|
||||||
// it opens.
|
|
||||||
separator := "?"
|
separator := "?"
|
||||||
if strings.Contains(s.config.DBURL, "?") {
|
if strings.Contains(s.config.DBURL, "?") {
|
||||||
separator = "&"
|
separator = "&"
|
||||||
@@ -264,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)
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the application tables created by 001.sql exist.
|
// Verify that the application tables created by 001.sql exist.
|
||||||
tables := []string{
|
tables := []string{"source_content", "negative_cache", "cache_stats"}
|
||||||
"source_content", "source_metadata", "output_content",
|
|
||||||
"request_cache", "negative_cache", "cache_stats",
|
|
||||||
}
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
|
|||||||
@@ -1,100 +1,108 @@
|
|||||||
-- Migration 001: Initial schema
|
-- Migration 001: Initial schema
|
||||||
-- Creates all tables for the pixa caching image proxy
|
-- Creates all tables for the pixa caching image proxy
|
||||||
|
|
||||||
-- Source content blobs
|
-- Source images, one row per source URL
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
||||||
-- last_accessed_at is NULL until the first LRU touch; eviction falls
|
-- is the SHA-256 of the source's host, path and query
|
||||||
-- 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,
|
source_key TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
last_accessed_at DATETIME
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
ON source_content(last_accessed_at);
|
ON source_content(last_accessed_at);
|
||||||
|
|
||||||
-- Source URL metadata - maps URLs to content hashes
|
|
||||||
-- JSON stored at: cache/metadata/<hostname>/<path_hash>.json
|
|
||||||
CREATE TABLE IF NOT EXISTS source_metadata (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_host TEXT NOT NULL,
|
|
||||||
source_path TEXT NOT NULL,
|
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
|
||||||
path_hash TEXT NOT NULL,
|
|
||||||
content_hash TEXT,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
content_type TEXT,
|
|
||||||
response_headers TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME,
|
|
||||||
etag TEXT,
|
|
||||||
last_modified TEXT,
|
|
||||||
UNIQUE(source_host, source_path, source_query),
|
|
||||||
FOREIGN KEY (content_hash) REFERENCES source_content(content_hash)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_host ON source_metadata(source_host);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_path_hash ON source_metadata(path_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
|
||||||
|
|
||||||
-- 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>. Tracked here
|
||||||
-- sidecar with the content type). Tracked here (like source content
|
-- (like source content blobs above) so total cache usage is known
|
||||||
-- blobs above) so total cache usage can be computed with a SUM query,
|
-- without a directory scan, and so LRU eviction has a timestamp to order
|
||||||
-- never a directory scan, and so LRU eviction has a timestamp to order
|
-- on. A cache hit does not read this table: it takes the content type
|
||||||
-- on.
|
-- from the file's first bytes, and its last_accessed_at is written
|
||||||
|
-- later, in a batch.
|
||||||
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,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
ON variant_content(last_accessed_at);
|
ON variant_content(last_accessed_at);
|
||||||
|
|
||||||
-- Output/transformed content blobs
|
-- Total cache usage: the sum of size_bytes over source_content and
|
||||||
-- Not written: transformed images are stored in cache/variants and
|
-- variant_content, kept by the triggers below in the same statement
|
||||||
-- tracked in variant_content above.
|
-- that adds, removes or resizes a row, so eviction reads this one row
|
||||||
CREATE TABLE IF NOT EXISTS output_content (
|
-- instead of summing both tables. Each trigger also adds one to
|
||||||
content_hash TEXT PRIMARY KEY,
|
-- change_count; the reconciliation pass, which sums both tables a page
|
||||||
content_type TEXT NOT NULL,
|
-- at a time, corrects total_size_bytes only if change_count did not
|
||||||
size_bytes INTEGER NOT NULL,
|
-- move while it summed.
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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);
|
||||||
|
|
||||||
-- Request cache - maps full request params to output content
|
CREATE TRIGGER IF NOT EXISTS source_content_usage_insert
|
||||||
CREATE TABLE IF NOT EXISTS request_cache (
|
AFTER INSERT ON source_content
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
BEGIN
|
||||||
cache_key TEXT NOT NULL UNIQUE,
|
UPDATE cache_usage
|
||||||
source_metadata_id INTEGER NOT NULL,
|
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
|
||||||
output_hash TEXT NOT NULL,
|
change_count = change_count + 1
|
||||||
width INTEGER NOT NULL,
|
WHERE id = 1;
|
||||||
height INTEGER NOT NULL,
|
END;
|
||||||
format TEXT NOT NULL,
|
|
||||||
quality INTEGER NOT NULL DEFAULT 85,
|
|
||||||
fit_mode TEXT NOT NULL DEFAULT 'cover',
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
access_count INTEGER NOT NULL DEFAULT 1,
|
|
||||||
FOREIGN KEY (source_metadata_id) REFERENCES source_metadata(id),
|
|
||||||
FOREIGN KEY (output_hash) REFERENCES output_content(content_hash)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_key ON request_cache(cache_key);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_source ON request_cache(source_metadata_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_output ON request_cache(output_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_fetched ON request_cache(fetched_at);
|
|
||||||
|
|
||||||
-- Negative cache for failed fetches (404s, timeouts, etc.)
|
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;
|
||||||
|
|
||||||
|
-- Negative cache: source URLs whose fetch failed with an upstream error
|
||||||
|
-- status, or whose first bytes did not match their Content-Type. pixa does
|
||||||
|
-- not fetch such a URL again until its row expires.
|
||||||
CREATE TABLE IF NOT EXISTS negative_cache (
|
CREATE TABLE IF NOT EXISTS negative_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
source_query TEXT NOT NULL DEFAULT '',
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
UNIQUE(source_host, source_path, source_query)
|
UNIQUE(source_host, source_path, source_query)
|
||||||
);
|
);
|
||||||
@@ -111,3 +119,11 @@ CREATE TABLE IF NOT EXISTS cache_stats (
|
|||||||
last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
|
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
|
||||||
|
|
||||||
|
-- A random value that a login session carries from its login. pixa makes it
|
||||||
|
-- when the table has no row, and each logout replaces it; a login session
|
||||||
|
-- carrying another value is refused, so logging out ends every login session.
|
||||||
|
CREATE TABLE IF NOT EXISTS logout (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
logout_value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|||||||
+52
-65
@@ -7,15 +7,15 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values for optional fields.
|
// Default values for optional fields.
|
||||||
const (
|
const (
|
||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imgcache.FormatOriginal
|
DefaultFormat = imagerequest.FormatJXL
|
||||||
DefaultFitMode = imgcache.FitCover
|
DefaultFitMode = imagerequest.FitCover
|
||||||
|
|
||||||
// HKDF salt for URL encryption key derivation
|
// HKDF salt for URL encryption key derivation
|
||||||
urlKeySalt = "pixa-urlenc-v1"
|
urlKeySalt = "pixa-urlenc-v1"
|
||||||
@@ -28,19 +28,19 @@ var (
|
|||||||
ErrDecryptFailed = errors.New("failed to decrypt URL")
|
ErrDecryptFailed = errors.New("failed to decrypt URL")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Payload contains all image request parameters in a compact format.
|
// payload contains all image request parameters in a compact format.
|
||||||
// Field names are short to minimize encoded size.
|
// Field names are short to minimize encoded size.
|
||||||
// Fields with omitempty use sane defaults when absent.
|
// Fields with omitempty use sane defaults when absent.
|
||||||
type Payload struct {
|
type payload struct {
|
||||||
SourceHost string `cbor:"h"` // required
|
SourceHost string `cbor:"h"` // required
|
||||||
SourcePath string `cbor:"p"` // required
|
SourcePath string `cbor:"p"` // required
|
||||||
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 imagerequest.Format `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 imagerequest.Fit `cbor:"fm,omitempty"` // default: cover
|
||||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generator creates and parses encrypted URL tokens.
|
// Generator creates and parses encrypted URL tokens.
|
||||||
@@ -59,9 +59,27 @@ func NewGenerator(signingKey string) (*Generator, error) {
|
|||||||
return &Generator{key: key}, nil
|
return &Generator{key: key}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates an encrypted URL token from the payload.
|
// Generate creates an encrypted URL token from the request's source,
|
||||||
// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded.
|
// transform and expiry, each as the request holds it; an empty format, a
|
||||||
func (g *Generator) Generate(p *Payload) (string, error) {
|
// quality of 0 or an empty fit mode is left out of the token, and a zero
|
||||||
|
// Expires makes a token that never expires. The token is CBOR-encoded,
|
||||||
|
// encrypted with NaCl secretbox, and base64url-encoded.
|
||||||
|
func (g *Generator) Generate(req *imagerequest.Request) (string, error) {
|
||||||
|
p := &payload{
|
||||||
|
SourceHost: req.Source.Host,
|
||||||
|
SourcePath: req.Source.Path,
|
||||||
|
SourceQuery: req.Source.Query,
|
||||||
|
Width: req.Transform.Size.Width,
|
||||||
|
Height: req.Transform.Size.Height,
|
||||||
|
Format: req.Transform.Format,
|
||||||
|
Quality: req.Transform.Quality,
|
||||||
|
FitMode: req.Transform.FitMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.Expires.IsZero() {
|
||||||
|
p.ExpiresAt = req.Expires.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
// CBOR encode the payload
|
// CBOR encode the payload
|
||||||
data, err := cbor.Marshal(p)
|
data, err := cbor.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,9 +90,11 @@ func (g *Generator) Generate(p *Payload) (string, error) {
|
|||||||
return seal.Encrypt(g.key, data)
|
return seal.Encrypt(g.key, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse decrypts and validates an encrypted URL token.
|
// Parse decrypts and validates an encrypted URL token and returns the
|
||||||
|
// request it holds, with default values for omitted optional fields. A token
|
||||||
|
// that never expires gives the zero Expires.
|
||||||
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
||||||
func (g *Generator) Parse(token string) (*Payload, error) {
|
func (g *Generator) Parse(token string) (*imagerequest.Request, error) {
|
||||||
// Decrypt
|
// Decrypt
|
||||||
data, err := seal.Decrypt(g.key, token)
|
data, err := seal.Decrypt(g.key, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,7 +107,7 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
var p Payload
|
var p payload
|
||||||
|
|
||||||
err = cbor.Unmarshal(data, &p)
|
err = cbor.Unmarshal(data, &p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,13 +119,6 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
return nil, ErrExpired
|
return nil, ErrExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToImageRequest converts the payload to an ImageRequest.
|
|
||||||
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
|
||||||
// that never expires, gives the zero Expires.
|
|
||||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
format = DefaultFormat
|
format = DefaultFormat
|
||||||
@@ -126,44 +139,18 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
expires = time.Unix(p.ExpiresAt, 0)
|
expires = time.Unix(p.ExpiresAt, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imagerequest.Request{
|
||||||
SourceHost: p.SourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: p.SourcePath,
|
Host: p.SourceHost,
|
||||||
SourceQuery: p.SourceQuery,
|
Path: p.SourcePath,
|
||||||
Size: imgcache.Size{
|
Query: p.SourceQuery,
|
||||||
Width: p.Width,
|
},
|
||||||
Height: p.Height,
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: p.Width, Height: p.Height},
|
||||||
|
Format: format,
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: fitMode,
|
||||||
},
|
},
|
||||||
Format: format,
|
|
||||||
Quality: quality,
|
|
||||||
FitMode: fitMode,
|
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
}
|
}, nil
|
||||||
}
|
|
||||||
|
|
||||||
// FromImageRequest creates a Payload from an ImageRequest with the given expiration.
|
|
||||||
func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload {
|
|
||||||
p := &Payload{
|
|
||||||
SourceHost: req.SourceHost,
|
|
||||||
SourcePath: req.SourcePath,
|
|
||||||
SourceQuery: req.SourceQuery,
|
|
||||||
Width: req.Size.Width,
|
|
||||||
Height: req.Size.Height,
|
|
||||||
ExpiresAt: expiresAt.Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set non-default values to benefit from omitempty
|
|
||||||
if req.Format != "" && req.Format != DefaultFormat {
|
|
||||||
p.Format = req.Format
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Quality != 0 && req.Quality != DefaultQuality {
|
|
||||||
p.Quality = req.Quality
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode != "" && req.FitMode != DefaultFitMode {
|
|
||||||
p.FitMode = req.FitMode
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-174
@@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared test fixture strings.
|
// Shared test fixture strings.
|
||||||
@@ -24,19 +24,22 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
t.Fatalf("NewGenerator() error = %v", err)
|
t.Fatalf("NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
SourceQuery: testSourceQuery,
|
Path: testSourcePath,
|
||||||
Width: 800,
|
Query: testSourceQuery,
|
||||||
Height: 600,
|
},
|
||||||
Format: imgcache.FormatWebP,
|
Transform: imagerequest.Transform{
|
||||||
Quality: 90,
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
FitMode: imgcache.FitContain,
|
Format: imagerequest.FormatWebP,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Quality: 90,
|
||||||
|
FitMode: imagerequest.FitContain,
|
||||||
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := gen.Generate(payload)
|
token, err := gen.Generate(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -51,40 +54,45 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all fields
|
// Verify all fields
|
||||||
if parsed.SourceHost != payload.SourceHost {
|
if parsed.Source.Host != req.Source.Host {
|
||||||
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", parsed.Source.Host, req.Source.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourcePath != payload.SourcePath {
|
if parsed.Source.Path != req.Source.Path {
|
||||||
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", parsed.Source.Path, req.Source.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourceQuery != payload.SourceQuery {
|
if parsed.Source.Query != req.Source.Query {
|
||||||
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", parsed.Source.Query, req.Source.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Width != payload.Width {
|
if parsed.Transform.Size.Width != req.Transform.Size.Width {
|
||||||
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
t.Errorf("Width = %d, want %d",
|
||||||
|
parsed.Transform.Size.Width, req.Transform.Size.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Height != payload.Height {
|
if parsed.Transform.Size.Height != req.Transform.Size.Height {
|
||||||
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
t.Errorf("Height = %d, want %d",
|
||||||
|
parsed.Transform.Size.Height, req.Transform.Size.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Format != payload.Format {
|
if parsed.Transform.Format != req.Transform.Format {
|
||||||
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
t.Errorf("Format = %q, want %q",
|
||||||
|
parsed.Transform.Format, req.Transform.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Quality != payload.Quality {
|
if parsed.Transform.Quality != req.Transform.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d",
|
||||||
|
parsed.Transform.Quality, req.Transform.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.FitMode != payload.FitMode {
|
if parsed.Transform.FitMode != req.Transform.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q",
|
||||||
|
parsed.Transform.FitMode, req.Transform.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.ExpiresAt != payload.ExpiresAt {
|
if parsed.Expires.Unix() != req.Expires.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
t.Errorf("ExpiresAt = %d, want %d", parsed.Expires.Unix(), req.Expires.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +101,15 @@ func TestGenerator_Parse_Expired(t *testing.T) {
|
|||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
|
Expires: time.Now().Add(-time.Hour), // Already expired
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := gen.Generate(payload)
|
token, err := gen.Generate(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -130,13 +140,15 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(payload)
|
token, _ := gen.Generate(req)
|
||||||
|
|
||||||
// Tamper with the token
|
// Tamper with the token
|
||||||
tampered := []byte(token)
|
tampered := []byte(token)
|
||||||
@@ -156,13 +168,15 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
|
|||||||
gen1, _ := encurl.NewGenerator("signing-key-1")
|
gen1, _ := encurl.NewGenerator("signing-key-1")
|
||||||
gen2, _ := encurl.NewGenerator("signing-key-2")
|
gen2, _ := encurl.NewGenerator("signing-key-2")
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen1.Generate(payload)
|
token, _ := gen1.Generate(req)
|
||||||
|
|
||||||
_, err := gen2.Parse(token)
|
_, err := gen2.Parse(token)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -170,140 +184,41 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest(t *testing.T) {
|
func TestGenerator_Parse_Defaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
// Request with only required fields - should get defaults
|
||||||
SourceQuery: testSourceQuery,
|
token, err := gen.Generate(&imagerequest.Request{
|
||||||
Width: 800,
|
Source: imagerequest.Source{
|
||||||
Height: 600,
|
Host: testSourceHost,
|
||||||
Format: imgcache.FormatWebP,
|
Path: testSourcePath,
|
||||||
Quality: 90,
|
},
|
||||||
FitMode: imgcache.FitContain,
|
Expires: time.Now().Add(time.Hour),
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
req, err := gen.Parse(token)
|
||||||
|
if err != nil {
|
||||||
if req.SourceHost != payload.SourceHost {
|
t.Fatalf("Parse() error = %v", err)
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != payload.SourcePath {
|
if req.Transform.Format != encurl.DefaultFormat {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
t.Errorf("Format = %q, want default %q",
|
||||||
|
req.Transform.Format, encurl.DefaultFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != payload.SourceQuery {
|
if req.Transform.Quality != encurl.DefaultQuality {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
t.Errorf("Quality = %d, want default %d",
|
||||||
|
req.Transform.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Width != payload.Width {
|
if req.Transform.FitMode != encurl.DefaultFitMode {
|
||||||
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
t.Errorf("FitMode = %q, want default %q",
|
||||||
}
|
req.Transform.FitMode, encurl.DefaultFitMode)
|
||||||
|
|
||||||
if req.Size.Height != payload.Height {
|
|
||||||
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Format != payload.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Quality != payload.Quality {
|
|
||||||
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode != payload.FitMode {
|
|
||||||
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Payload with only required fields - should get defaults
|
|
||||||
payload := &encurl.Payload{
|
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
|
||||||
|
|
||||||
if req.Format != encurl.DefaultFormat {
|
|
||||||
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Quality != encurl.DefaultQuality {
|
|
||||||
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode != encurl.DefaultFitMode {
|
|
||||||
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromImageRequest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := &imgcache.ImageRequest{
|
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
SourceQuery: testSourceQuery,
|
|
||||||
Size: imgcache.Size{Width: 800, Height: 600},
|
|
||||||
Format: imgcache.FormatWebP,
|
|
||||||
Quality: 90,
|
|
||||||
FitMode: imgcache.FitContain,
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Hour)
|
|
||||||
payload := encurl.FromImageRequest(req, expiresAt)
|
|
||||||
|
|
||||||
if payload.SourceHost != req.SourceHost {
|
|
||||||
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.SourcePath != req.SourcePath {
|
|
||||||
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Width != req.Size.Width {
|
|
||||||
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.ExpiresAt != expiresAt.Unix() {
|
|
||||||
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Request with default values - payload should omit them for
|
|
||||||
// smaller encoding
|
|
||||||
req := &imgcache.ImageRequest{
|
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
Format: encurl.DefaultFormat,
|
|
||||||
Quality: encurl.DefaultQuality,
|
|
||||||
FitMode: encurl.DefaultFitMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
|
|
||||||
|
|
||||||
// These should be zero/empty because they match defaults
|
|
||||||
if payload.Format != "" {
|
|
||||||
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Quality != 0 {
|
|
||||||
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.FitMode != "" {
|
|
||||||
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,13 +227,15 @@ func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
|||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(payload)
|
token, _ := gen.Generate(req)
|
||||||
|
|
||||||
// Check that token only contains URL-safe characters
|
// Check that token only contains URL-safe characters
|
||||||
for _, c := range token {
|
for _, c := range token {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+116
-38
@@ -9,12 +9,14 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/clientip"
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,10 +91,18 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLogout clears the session and redirects to login.
|
// HandleLogout logs out, which ends every login session (see
|
||||||
|
// session.Manager.Logout), and redirects to login.
|
||||||
func (s *Handlers) HandleLogout() http.HandlerFunc {
|
func (s *Handlers) HandleLogout() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.sessMgr.ClearSession(w)
|
err := s.sessMgr.Logout(w, r)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to log out", "error", err)
|
||||||
|
http.Error(w, "Failed to log out", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +127,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
sourceURL := r.FormValue("url")
|
sourceURL := r.FormValue("url")
|
||||||
|
|
||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := parseSourceURL(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@@ -125,7 +135,17 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
// /v1/image/ refuses a ".." segment in the source path, and so does
|
||||||
|
// the generator. The path is decoded, so %2e%2e counts too
|
||||||
|
if slices.Contains(strings.Split(parsed.Path, "/"), "..") {
|
||||||
|
s.renderGeneratorWithForm(w, r,
|
||||||
|
"Source URL path cannot hold a .. segment", r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, expiresAt, ttl, err := buildGenerateRequest(parsed, r.Form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@@ -133,8 +153,20 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The image URL must fetch the source URL as it was written
|
||||||
|
req.AllowHTTP = s.config.AllowHTTP
|
||||||
|
fetchURL := req.SourceURL()
|
||||||
|
|
||||||
|
if !fetchedAsWritten(parsed, fetchURL) {
|
||||||
|
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
||||||
|
"written: pixa would fetch "+fetchURL,
|
||||||
|
r.Form, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
@@ -165,14 +197,57 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
// parseSourceURL parses the source URL entered on the generator page with its
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
// host lower-cased and an empty path written as "/", as /v1/image/ reads a
|
||||||
// non-numeric or out-of-range width, height, quality or ttl, or an
|
// source URL, so that each spelling of one source URL makes one cache key and
|
||||||
// unrecognized fit mode, is a client error naming the offending field. The
|
// one upstream request.
|
||||||
// format field is passed through unchecked.
|
func parseSourceURL(sourceURL string) (*url.URL, error) {
|
||||||
func buildGeneratePayload(
|
parsed, err := url.Parse(sourceURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.Host = strings.ToLower(parsed.Host)
|
||||||
|
|
||||||
|
if parsed.Path == "" {
|
||||||
|
parsed.Path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchedAsWritten reports whether fetchURL, the URL pixa fetches for an
|
||||||
|
// image URL made from the source URL source, is source as it was written,
|
||||||
|
// apart from the scheme, which the token does not keep. It is not when source
|
||||||
|
// holds a user name and password, which the token does not keep either, or a
|
||||||
|
// "?" with no query after it. The token keeps the source path decoded and
|
||||||
|
// pixa fetches it percent-encoded again, so a path written another way, such
|
||||||
|
// as with %2F, is fetched as another path. url.URL keeps the path as written
|
||||||
|
// in RawPath when it is not the decoded path percent-encoded again.
|
||||||
|
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
|
||||||
|
// such as a space, so RawPath is compared itself.
|
||||||
|
func fetchedAsWritten(source *url.URL, fetchURL string) bool {
|
||||||
|
writtenPath := source.RawPath
|
||||||
|
if writtenPath == "" {
|
||||||
|
writtenPath = source.EscapedPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched, err := url.Parse(fetchURL)
|
||||||
|
|
||||||
|
return err == nil && source.User == nil && !source.ForceQuery &&
|
||||||
|
fetched.EscapedPath() == writtenPath &&
|
||||||
|
fetched.RawQuery == source.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGenerateRequest parses the numeric form fields and assembles the
|
||||||
|
// image request the encrypted URL's token is made from. ttl=0 means never
|
||||||
|
// expires (Expires stays the zero time). A non-numeric or out-of-range
|
||||||
|
// width, height, quality or ttl, or an unrecognized fit mode, is a client
|
||||||
|
// error naming the offending field. The format field is passed through
|
||||||
|
// unchecked.
|
||||||
|
func buildGenerateRequest(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*encurl.Payload, time.Time, int, error) {
|
) (*imagerequest.Request, time.Time, int, error) {
|
||||||
width, err := parseFormDimension(form, "width")
|
width, err := parseFormDimension(form, "width")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, 0, err
|
return nil, time.Time{}, 0, err
|
||||||
@@ -194,42 +269,40 @@ func buildGeneratePayload(
|
|||||||
return nil, time.Time{}, 0, err
|
return nil, time.Time{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fitMode := imgcache.FitMode(form.Get("fit"))
|
fitMode := imagerequest.Fit(form.Get("fit"))
|
||||||
|
|
||||||
err = imgcache.ValidateFitMode(fitMode)
|
err = imagerequest.ValidateFitMode(fitMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, 0,
|
return nil, time.Time{}, 0,
|
||||||
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, form.Get("fit"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var expiresAt time.Time
|
||||||
expiresAt time.Time
|
|
||||||
expiresAtUnix int64
|
|
||||||
)
|
|
||||||
|
|
||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
expiresAtUnix = expiresAt.Unix()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: parsed.Host,
|
Source: imagerequest.Source{
|
||||||
SourcePath: parsed.Path,
|
Host: parsed.Host,
|
||||||
SourceQuery: parsed.RawQuery,
|
Path: parsed.Path,
|
||||||
Width: width,
|
Query: parsed.RawQuery,
|
||||||
Height: height,
|
},
|
||||||
Format: imgcache.ImageFormat(form.Get("format")),
|
Transform: imagerequest.Transform{
|
||||||
Quality: quality,
|
Size: imagerequest.Size{Width: width, Height: height},
|
||||||
FitMode: fitMode,
|
Format: imagerequest.Format(form.Get("format")),
|
||||||
ExpiresAt: expiresAtUnix,
|
Quality: quality,
|
||||||
|
FitMode: fitMode,
|
||||||
|
},
|
||||||
|
Expires: expiresAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, expiresAt, ttl, nil
|
return req, expiresAt, ttl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFormDimension reads an optional width or height form field. An empty
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
// value means "original size" (0). A non-numeric value, or one
|
// value means "original size" (0). A non-numeric value, or one
|
||||||
// imgcache.ValidateDimension rejects, is an error naming the field.
|
// imagerequest.ValidateDimension rejects, is an error naming the field.
|
||||||
func parseFormDimension(form url.Values, field string) (int, error) {
|
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||||
raw := form.Get(field)
|
raw := form.Get(field)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -241,7 +314,7 @@ func parseFormDimension(form url.Values, field string) (int, error) {
|
|||||||
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = imgcache.ValidateDimension(field, value)
|
err = imagerequest.ValidateDimension(field, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -369,10 +442,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
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generator form field names, and a value that is not a number.
|
// Generator form field names, and a value that is not a number.
|
||||||
@@ -125,10 +125,10 @@ func TestGeneratePostRejectsBadField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
// TestBuildGenerateRequestDefaultAndLimits verifies that an empty quality
|
||||||
// takes the default, and that the largest accepted width, height, quality and
|
// takes the default, and that the largest accepted width, height, quality and
|
||||||
// ttl are accepted with an expiry still in the future.
|
// ttl are accepted with an expiry still in the future.
|
||||||
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
func TestBuildGenerateRequestDefaultAndLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
parsed, err := url.Parse(testSourceURL)
|
parsed, err := url.Parse(testSourceURL)
|
||||||
@@ -136,19 +136,19 @@ func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
|||||||
t.Fatalf("url.Parse() error = %v", err)
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
req, _, _, err := buildGenerateRequest(parsed, url.Values{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("empty form: error = %v", err)
|
t.Fatalf("empty form: error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Quality != encurl.DefaultQuality {
|
if req.Transform.Quality != encurl.DefaultQuality {
|
||||||
t.Errorf("empty quality gave %d, want %d",
|
t.Errorf("empty quality gave %d, want %d",
|
||||||
payload.Quality, encurl.DefaultQuality)
|
req.Transform.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
_, expiresAt, _, err := buildGenerateRequest(parsed, url.Values{
|
||||||
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
widthField: {strconv.Itoa(imagerequest.MaxDimension)},
|
||||||
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
heightField: {strconv.Itoa(imagerequest.MaxDimension)},
|
||||||
qualityField: {strconv.Itoa(maxQuality)},
|
qualityField: {strconv.Itoa(maxQuality)},
|
||||||
ttlField: {strconv.Itoa(maxTTL)},
|
ttlField: {strconv.Itoa(maxTTL)},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,6 +185,98 @@ func TestHandleLogout_ClearsSessionCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleLogout_EndsCopiedSession verifies that after POST /logout a copy
|
||||||
|
// of the session cookie taken before it shows the login page, not the
|
||||||
|
// generator page.
|
||||||
|
func TestHandleLogout_EndsCopiedSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
copied := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/logout", nil)
|
||||||
|
req.AddCookie(copied)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleLogout().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /logout status = %d, want %d",
|
||||||
|
rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(copied)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
t.Logf("GET / with the copied cookie: status %d, login page %v",
|
||||||
|
rec.Code, strings.Contains(body, loginForm))
|
||||||
|
|
||||||
|
if !strings.Contains(body, loginForm) || strings.Contains(body, generatorForm) {
|
||||||
|
t.Error("a cookie copied before /logout still opens the generator page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleLogout_SaveFails_Answers500 verifies that when the logout cannot
|
||||||
|
// be saved in the database, POST /logout answers 500, leaves the session
|
||||||
|
// cookie in place and ends no session.
|
||||||
|
func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManagerWithDatabase(
|
||||||
|
t.Context(), testSigningKey, db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sessMgr = sessMgr
|
||||||
|
cookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
// Saving the logout fails on a closed database.
|
||||||
|
err = db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("closing the database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/logout", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleLogout().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
t.Logf("POST /logout: status %d, Set-Cookie %q",
|
||||||
|
rec.Code, rec.Header().Get("Set-Cookie"))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("POST /logout status = %d, want %d",
|
||||||
|
rec.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := findSessionCookie(rec); c != nil {
|
||||||
|
t.Errorf("a logout that failed set the session cookie: %s", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), generatorForm) {
|
||||||
|
t.Error("a logout that failed ended the session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
||||||
// with a valid CSRF token but no login session answers 303 to / and makes no
|
// with a valid CSRF token but no login session answers 303 to / and makes no
|
||||||
// URL.
|
// URL.
|
||||||
@@ -222,7 +314,7 @@ func TestGeneratePost_URLServesImage(t *testing.T) {
|
|||||||
sourceURLField: {"https://" + signedHost + photoPath},
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
widthField: {"50"},
|
widthField: {"50"},
|
||||||
heightField: {"50"},
|
heightField: {"50"},
|
||||||
formatField: {string(imgcache.FormatJPEG)},
|
formatField: {string(imagerequest.FormatJPEG)},
|
||||||
})
|
})
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -255,7 +347,7 @@ func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
|
|||||||
sourceURLField: {"https://" + signedHost + photoPath},
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
widthField: {"50"},
|
widthField: {"50"},
|
||||||
heightField: {"50"},
|
heightField: {"50"},
|
||||||
formatField: {string(imgcache.FormatJPEG)},
|
formatField: {string(imagerequest.FormatJPEG)},
|
||||||
ttlField: {"1"},
|
ttlField: {"1"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and
|
||||||
|
// handleImageError, which answer the errors of the two image routes, answer
|
||||||
|
// 400 naming the AVIF pixel limit when the image processor refuses an AVIF
|
||||||
|
// output above it, wrapped as the service wraps it. The request's source only
|
||||||
|
// goes into a log line, so it is left empty.
|
||||||
|
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
err := fmt.Errorf("image processing failed: %w: 4097x4096",
|
||||||
|
imageprocessor.ErrAVIFOutputTooLarge)
|
||||||
|
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
||||||
|
|
||||||
|
plain := httptest.NewRecorder()
|
||||||
|
h.respondImageError(plain, &imagerequest.Request{}, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"respondImageError": plain, "handleImageError": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: status = %d, want %d",
|
||||||
|
name, rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), wantBody) {
|
||||||
|
t.Errorf("%s: body = %q, want it to say %q",
|
||||||
|
name, rec.Body.String(), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingHost is the allowlisted host whose photoPath the server of
|
||||||
|
// newCountingDatabaseServer serves.
|
||||||
|
const countingHost = "counting.example.com"
|
||||||
|
|
||||||
|
// countingConnector opens connections to the SQLite database file name with
|
||||||
|
// sqliteDriver, each one counting in statements the statements run on it.
|
||||||
|
type countingConnector struct {
|
||||||
|
sqliteDriver driver.Driver
|
||||||
|
name string
|
||||||
|
statements *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConnector) Connect(context.Context) (driver.Conn, error) {
|
||||||
|
conn, err := c.sqliteDriver.Open(c.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return countingConn{Conn: conn, statements: c.statements}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConnector) Driver() driver.Driver {
|
||||||
|
return c.sqliteDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingConn counts the statements run on its connection. It has only the
|
||||||
|
// methods of driver.Conn, so database/sql prepares every query and every
|
||||||
|
// other statement with Prepare, which counts it.
|
||||||
|
type countingConn struct {
|
||||||
|
driver.Conn
|
||||||
|
|
||||||
|
statements *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConn) Prepare(query string) (driver.Stmt, error) {
|
||||||
|
c.statements.Add(1)
|
||||||
|
|
||||||
|
return c.Conn.Prepare(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountingDatabase returns a database with pixa's schema, in a file of
|
||||||
|
// its own and with one connection, and the count of the statements run on
|
||||||
|
// it once the schema is in place.
|
||||||
|
func newCountingDatabase(tb testing.TB) (*sql.DB, *atomic.Int64) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(tb.TempDir(), "state.sqlite3")
|
||||||
|
|
||||||
|
schemaDB, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("opening the database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = schemaDB.Close() }()
|
||||||
|
|
||||||
|
err = database.ApplyMigrations(tb.Context(), schemaDB, nil)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("applying the migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := new(atomic.Int64)
|
||||||
|
|
||||||
|
db := sql.OpenDB(countingConnector{
|
||||||
|
sqliteDriver: schemaDB.Driver(),
|
||||||
|
name: path,
|
||||||
|
statements: statements,
|
||||||
|
})
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
tb.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db, statements
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountingDatabaseServer returns a router for both image routes whose
|
||||||
|
// image cache uses newCountingDatabase, the count of the statements run on
|
||||||
|
// that database, and a URL on each route for a different image of
|
||||||
|
// photoPath on countingHost. Nothing writes the cache's pending counts and
|
||||||
|
// access times in the background, so every statement counted comes from a
|
||||||
|
// request.
|
||||||
|
func newCountingDatabaseServer(
|
||||||
|
tb testing.TB,
|
||||||
|
) (http.Handler, *atomic.Int64, []string) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
db, statements := newCountingDatabase(tb)
|
||||||
|
|
||||||
|
var photo bytes.Buffer
|
||||||
|
|
||||||
|
err := jpeg.Encode(&photo, image.NewRGBA(image.Rect(0, 0, 100, 100)), nil)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encoding the photo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
|
StateDir: tb.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
|
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
|
||||||
|
}),
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
Allowlist: []string{countingHost},
|
||||||
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 40, Height: 40},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encGen.Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
imgSvc: svc,
|
||||||
|
encGen: encGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return r, statements, []string{
|
||||||
|
"/v1/image/" + countingHost + photoPath + "/50x50.jpeg",
|
||||||
|
"/v1/e/" + token + "/img.jpg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveImage sends srv a GET for target and returns the response, failing
|
||||||
|
// the test unless it is a 200 with an image.
|
||||||
|
func serveImage(
|
||||||
|
tb testing.TB, srv http.Handler, target string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec,
|
||||||
|
httptest.NewRequestWithContext(tb.Context(), http.MethodGet, target, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.Len() == 0 {
|
||||||
|
tb.Errorf("GET %s: %d with %d bytes, want 200 with an image",
|
||||||
|
target, rec.Code, rec.Body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_CacheHitsMakeNoDatabaseCall serves an image on each image
|
||||||
|
// route once, which makes and caches it, then serves it again from the cache.
|
||||||
|
// Serving those cache hits must run no statement on the database.
|
||||||
|
func TestImageRoutes_CacheHitsMakeNoDatabaseCall(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv, statements, targets := newCountingDatabaseServer(t)
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
before := statements.Load()
|
||||||
|
|
||||||
|
serveImage(t, srv, target)
|
||||||
|
|
||||||
|
afterMiss := statements.Load()
|
||||||
|
t.Logf("GET %s, a miss: %d statements", target, afterMiss-before)
|
||||||
|
|
||||||
|
if afterMiss == before {
|
||||||
|
t.Fatalf("GET %s, a miss: no statement counted, want some", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
rec := serveImage(t, srv, target)
|
||||||
|
|
||||||
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
if cacheStatus != string(imgcache.CacheHit) {
|
||||||
|
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imgcache.CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hitStatements := statements.Load() - afterMiss; hitStatements != 0 {
|
||||||
|
t.Errorf("GET %s: 3 cache hits ran %d statements, want 0",
|
||||||
|
target, hitStatements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkHandleImage_CacheHits serves one cached image through the
|
||||||
|
// /v1/image/ route from b.RunParallel's goroutines, and reports the cache
|
||||||
|
// hits served per second. It fails if serving them ran any statement on the
|
||||||
|
// database. script/bench runs it.
|
||||||
|
func BenchmarkHandleImage_CacheHits(b *testing.B) {
|
||||||
|
srv, statements, targets := newCountingDatabaseServer(b)
|
||||||
|
target := targets[0]
|
||||||
|
|
||||||
|
serveImage(b, srv, target)
|
||||||
|
|
||||||
|
before := statements.Load()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
rec := serveImage(b, srv, target)
|
||||||
|
|
||||||
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
if cacheStatus != string(imgcache.CacheHit) {
|
||||||
|
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imgcache.CacheHit)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "hits/s")
|
||||||
|
|
||||||
|
if hitStatements := statements.Load() - before; hitStatements != 0 {
|
||||||
|
b.Fatalf("%d cache hits ran %d statements, want 0", b.N, hitStatements)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"image/color"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mismatchedSourcePath is the path of the source that
|
||||||
|
// TestFirstBytesNotMatchingContentType serves on allowlistedHost. The mock
|
||||||
|
// fetcher names a file's Content-Type from its extension, so the source is
|
||||||
|
// sent as image/png.
|
||||||
|
const mismatchedSourcePath = "/images/source.png"
|
||||||
|
|
||||||
|
// newRoutesFetchingThrough returns both image routes, and the Handlers behind
|
||||||
|
// them, whose image service fetches through fetcher and has allowlistedHost on
|
||||||
|
// its allowlist. The handlers and the image service log to log.
|
||||||
|
func newRoutesFetchingThrough(
|
||||||
|
t *testing.T, fetcher httpfetcher.Fetcher, log *slog.Logger,
|
||||||
|
) (*Handlers, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
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},
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
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: log, imgSvc: svc, encGen: encGen}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return h, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// mismatchedSourcePlainURL returns the /v1/image/ URL of mismatchedSourcePath
|
||||||
|
// on allowlistedHost at 50x50 as a JPEG.
|
||||||
|
func mismatchedSourcePlainURL(*testing.T, *Handlers) string {
|
||||||
|
return "/v1/image/" + allowlistedHost + mismatchedSourcePath + "/50x50.jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// mismatchedSourceEncURL returns an encrypted /v1/e/ URL, made with h's
|
||||||
|
// generator, of mismatchedSourcePath on allowlistedHost at 50x50 as a JPEG.
|
||||||
|
func mismatchedSourceEncURL(t *testing.T, h *Handlers) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: allowlistedHost,
|
||||||
|
Path: mismatchedSourcePath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/v1/e/" + token + "/img.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggedLevels reads the JSON log lines in logs and returns the messages
|
||||||
|
// logged at each level.
|
||||||
|
func loggedLevels(t *testing.T, logs *bytes.Buffer) map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Logf("logged:\n%s", logs.String())
|
||||||
|
|
||||||
|
messages := make(map[string][]string)
|
||||||
|
|
||||||
|
dec := json.NewDecoder(logs)
|
||||||
|
for dec.More() {
|
||||||
|
var line struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := dec.Decode(&line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding log line: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages[line.Level] = append(messages[line.Level], line.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstBytesNotMatchingContentType checks that both image routes answer
|
||||||
|
// 415 for an upstream body whose first bytes are not those of the format its
|
||||||
|
// Content-Type names, and log it as a refused source, not as an error of
|
||||||
|
// pixa's. The next request for that source must answer 502 without fetching
|
||||||
|
// it again, as after an upstream error status.
|
||||||
|
func TestFirstBytesNotMatchingContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
bodies := map[string][]byte{
|
||||||
|
"JPEG sent as image/png": generateTestJPEG(t, 10, 10, color.Black),
|
||||||
|
"text sent as image/png": []byte("This is just text, not a PNG"),
|
||||||
|
"body too short to name a format": []byte("short"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source's URL on each image route.
|
||||||
|
sourceURLs := map[string]func(*testing.T, *Handlers) string{
|
||||||
|
"plain URL": mismatchedSourcePlainURL,
|
||||||
|
"encrypted URL": mismatchedSourceEncURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
for bodyName, body := range bodies {
|
||||||
|
for urlName, sourceURL := range sourceURLs {
|
||||||
|
t.Run(urlName+", "+bodyName, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fetcher := &countingFetcher{
|
||||||
|
Fetcher: httpfetcher.NewMock(fstest.MapFS{
|
||||||
|
allowlistedHost + mismatchedSourcePath: &fstest.MapFile{
|
||||||
|
Data: body,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
h, routes := newRoutesFetchingThrough(t, fetcher,
|
||||||
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||||
|
target := sourceURL(t, h)
|
||||||
|
|
||||||
|
checkErrorBody(t, sendGet(t, routes, target),
|
||||||
|
http.StatusUnsupportedMediaType, "unsupported source format")
|
||||||
|
|
||||||
|
levels := loggedLevels(t, &logs)
|
||||||
|
if len(levels[slog.LevelError.String()]) != 0 {
|
||||||
|
t.Errorf("logged at error level: %q",
|
||||||
|
levels[slog.LevelError.String()])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(levels[slog.LevelWarn.String()], "source refused") {
|
||||||
|
t.Error(`no "source refused" line logged at warning level`)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkErrorBody(t, sendGet(t, routes, target),
|
||||||
|
http.StatusBadGateway, "upstream error")
|
||||||
|
|
||||||
|
if n := fetcher.fetches.Load(); n != 1 {
|
||||||
|
t.Errorf("upstream fetched %d times, want 1", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 *imagerequest.Request,
|
||||||
|
) bool {
|
||||||
|
if req.Transform.Format != imagerequest.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.Transform.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) (imagerequest.Format, error) {
|
||||||
|
qualities, err := parseAccept(accept)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(qualities) == 0 {
|
||||||
|
return imagerequest.FormatJPEG, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualities["image/jxl"] > 0 {
|
||||||
|
return imagerequest.FormatJXL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualities["image/avif"] > 0 {
|
||||||
|
return imagerequest.FormatAVIF, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualities["image/webp"] > 0 {
|
||||||
|
return imagerequest.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 imagerequest.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,320 @@
|
|||||||
|
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/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 imagerequest.Format
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"AVIF-capable browser",
|
||||||
|
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
|
imagerequest.FormatAVIF, nil},
|
||||||
|
{"WebP-capable browser",
|
||||||
|
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
||||||
|
imagerequest.FormatWebP, nil},
|
||||||
|
{"WebP only", webpType, imagerequest.FormatWebP, nil},
|
||||||
|
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imagerequest.FormatJPEG, nil},
|
||||||
|
{"wildcard only", "*/*", imagerequest.FormatJPEG, nil},
|
||||||
|
{"image wildcard only", "image/*", imagerequest.FormatJPEG, nil},
|
||||||
|
{"absent", "", imagerequest.FormatJPEG, nil},
|
||||||
|
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imagerequest.FormatWebP, nil},
|
||||||
|
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
||||||
|
imagerequest.FormatJPEG, nil},
|
||||||
|
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imagerequest.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("", &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatAuto,
|
||||||
|
},
|
||||||
|
}, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatAuto,
|
||||||
|
},
|
||||||
|
Expires: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
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("", &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.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,6 +4,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -71,16 +72,21 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 and access times 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),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,7 +106,6 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|||||||
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
||||||
return imgcache.CacheConfig{
|
return imgcache.CacheConfig{
|
||||||
StateDir: cfg.StateDir,
|
StateDir: cfg.StateDir,
|
||||||
CacheTTL: imgcache.DefaultCacheTTL,
|
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||||
MaxBytes: cfg.CacheMaxBytes,
|
MaxBytes: cfg.CacheMaxBytes,
|
||||||
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
||||||
@@ -119,9 +124,14 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgCache = cache
|
s.imgCache = cache
|
||||||
|
|
||||||
// Background eviction: startup reconciliation, then periodic and
|
// Background eviction: startup reconciliation, then periodic and
|
||||||
// write-pressure passes. No-op when the disk cache is disabled.
|
// write-pressure passes, and the removal of expired negative cache
|
||||||
|
// rows, which is all it does when the disk cache is disabled.
|
||||||
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
||||||
|
|
||||||
|
// Writes the counts and the access times of cache hits that requests
|
||||||
|
// hand over, so that no request waits for the database to record them
|
||||||
|
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
|
||||||
@@ -155,7 +165,10 @@ func (s *Handlers) initImageService() error {
|
|||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load
|
// Initialize session manager (signing key is validated at config load
|
||||||
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||||
sessMgr, err := session.NewManager(s.config.SigningKey)
|
// The random value that each logout replaces is saved in the database,
|
||||||
|
// so it outlasts a restart.
|
||||||
|
sessMgr, err := session.NewManagerWithDatabase(
|
||||||
|
context.Background(), s.config.SigningKey, s.db.DB())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+82
-30
@@ -9,16 +9,18 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
if s.refuseBlockedReferer(w, r) {
|
||||||
@@ -34,8 +36,8 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
err := s.imgSvc.ValidateRequest(req)
|
err := s.imgSvc.ValidateRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("signature validation failed",
|
s.log.Warn("signature validation failed",
|
||||||
"host", req.SourceHost,
|
"host", req.Source.Host,
|
||||||
"path", req.SourcePath,
|
"path", req.Source.Path,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -43,8 +45,13 @@ 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 := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
// Get the image (from cache or fetch/process)
|
// Get the image (from cache or fetch/process)
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -74,20 +81,21 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageRequest parses the wildcard path and query parameters into
|
// parseImageRequest parses the image URL's path and query parameters into
|
||||||
// an ImageRequest. On invalid input it writes an error response and
|
// an image request. On invalid input it writes an error response and
|
||||||
// returns false.
|
// returns false.
|
||||||
func (s *Handlers) parseImageRequest(
|
func (s *Handlers) parseImageRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imgcache.ImageRequest, bool) {
|
) (*imagerequest.Request, bool) {
|
||||||
// Get the wildcard path from chi
|
// The path is parsed decoded once, as r.URL.Path holds it, and not from
|
||||||
pathParam := chi.URLParam(r, "*")
|
// chi's wildcard: chi routes on the path as the client wrote it whenever
|
||||||
|
// that is not Go's own encoding of it, so its wildcard would still hold
|
||||||
// Parse the URL path
|
// the %3F before a source query when, say, a "/" in the query is written
|
||||||
parsed, err := imgcache.ParseImagePath(pathParam)
|
// as %2F
|
||||||
|
req, err := imagerequest.ParseImageURL(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to parse image URL",
|
s.log.Warn("failed to parse image URL",
|
||||||
"path", pathParam,
|
"path", r.URL.Path,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
||||||
@@ -95,9 +103,6 @@ func (s *Handlers) parseImageRequest(
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to ImageRequest
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
// Parse signature params from query string. r.URL.Query() would silently
|
// Parse signature params from query string. r.URL.Query() would silently
|
||||||
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
||||||
// at 85; a query string that cannot be decoded is refused instead. A
|
// at 85; a query string that cannot be decoded is refused instead. A
|
||||||
@@ -141,7 +146,7 @@ func (s *Handlers) parseImageRequest(
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Quality, err = parseFormInt(query, "q",
|
req.Transform.Quality, err = parseFormInt(query, "q",
|
||||||
encurl.DefaultQuality, minQuality, maxQuality)
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
||||||
@@ -153,23 +158,23 @@ func (s *Handlers) parseImageRequest(
|
|||||||
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
||||||
// fit mode is refused by the fit-mode check below; that check would take an
|
// fit mode is refused by the fit-mode check below; that check would take an
|
||||||
// empty fit as missing, so an empty one is refused here.
|
// empty fit as missing, so an empty one is refused here.
|
||||||
req.FitMode = imgcache.FitMode(query.Get("fit"))
|
req.Transform.FitMode = imagerequest.Fit(query.Get("fit"))
|
||||||
if query.Has("fit") && req.FitMode == "" {
|
if query.Has("fit") && req.Transform.FitMode == "" {
|
||||||
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
if req.FitMode == "" {
|
if req.Transform.FitMode == "" {
|
||||||
req.FitMode = imgcache.FitCover
|
req.Transform.FitMode = imagerequest.FitCover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||||
// route. Dimensions are already bounded by the path parser above; this
|
// route. Dimensions are already bounded by the path parser above; this
|
||||||
// also rejects an unrecognized fit mode with 400 instead of letting it
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||||
// reach the processor as a 500.
|
// reach the processor as a 500.
|
||||||
err = imgcache.ValidateImageRequest(req)
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, "invalid image request: "+err.Error(),
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@@ -200,13 +205,35 @@ 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 *imagerequest.Request, err error,
|
||||||
) {
|
) {
|
||||||
|
// An upstream body whose first bytes are not those of the format its
|
||||||
|
// Content-Type names is the upstream's error, not pixa's: the image
|
||||||
|
// service has logged it as a refused source.
|
||||||
|
if errors.Is(err, magic.ErrMagicByteMismatch) ||
|
||||||
|
errors.Is(err, magic.ErrUnknownFormat) ||
|
||||||
|
errors.Is(err, magic.ErrNotEnoughData) {
|
||||||
|
s.respondError(w, "unsupported source format",
|
||||||
|
http.StatusUnsupportedMediaType)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.log.Error("failed to get image",
|
s.log.Error("failed to get image",
|
||||||
"host", req.SourceHost,
|
"host", req.Source.Host,
|
||||||
"path", req.SourcePath,
|
"path", req.Source.Path,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,13 +251,38 @@ func (s *Handlers) respondImageError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages) {
|
errors.Is(err, imageprocessor.ErrTooManyImages) ||
|
||||||
|
errors.Is(err, imgcache.ErrProcessingTimedOut) {
|
||||||
s.respondError(w, "server busy, try again later",
|
s.respondError(w, "server busy, try again later",
|
||||||
http.StatusServiceUnavailable)
|
http.StatusServiceUnavailable)
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge) {
|
||||||
|
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
||||||
|
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +344,8 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
|||||||
// handling conditional and HEAD requests.
|
// handling conditional and HEAD requests.
|
||||||
func (s *Handlers) writeImageResponse(
|
func (s *Handlers) writeImageResponse(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
req *imagerequest.Request, resp *imgcache.ImageResponse,
|
||||||
cacheKey imgcache.VariantKey, startTime time.Time,
|
cacheKey imagerequest.VariantKey, startTime time.Time,
|
||||||
) {
|
) {
|
||||||
// Set response headers
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
@@ -334,7 +386,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
"cache_key", cacheKey,
|
"cache_key", cacheKey,
|
||||||
"cache_status", resp.CacheStatus,
|
"cache_status", resp.CacheStatus,
|
||||||
"duration_ms", duration.Milliseconds(),
|
"duration_ms", duration.Milliseconds(),
|
||||||
"format", req.Format,
|
"format", req.Transform.Format,
|
||||||
"served_bytes", servedBytes,
|
"served_bytes", servedBytes,
|
||||||
"fetched_bytes", resp.FetchedBytes,
|
"fetched_bytes", resp.FetchedBytes,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +32,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 {
|
||||||
@@ -108,11 +108,15 @@ func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
@@ -169,13 +173,13 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
expiresAt int64
|
expires time.Time
|
||||||
wantAtLeast int
|
wantAtLeast int
|
||||||
wantAtMost int
|
wantAtMost int
|
||||||
}{
|
}{
|
||||||
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
|
{"60 second TTL", time.Now().Add(time.Minute), 50, 60},
|
||||||
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
|
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour), 31536000, 31536000},
|
||||||
{"no TTL", 0, 31536000, 31536000},
|
{"no TTL", time.Time{}, 31536000, 31536000},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -184,13 +188,16 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
ExpiresAt: tt.expiresAt,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
Expires: tt.expires,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
@@ -29,7 +30,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 {
|
||||||
@@ -85,15 +85,18 @@ func photoURLWithSig(host, sig string, expires time.Time) string {
|
|||||||
// photoSignature returns the signature of photoURL(host) at the default
|
// photoSignature returns the signature of photoURL(host) at the default
|
||||||
// quality and fit, made with key and expiring at expires.
|
// quality and fit, made with key and expiring at expires.
|
||||||
func photoSignature(key, host string, expires time.Time) string {
|
func photoSignature(key, host string, expires time.Time) string {
|
||||||
return signature.New(key).Sign(&signature.Request{
|
return signature.New(key).Sign(&imagerequest.Request{
|
||||||
SourceHost: host,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: host,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
Quality: 85,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJPEG,
|
||||||
Expires: expires,
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
Expires: expires,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,3 +268,130 @@ func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidHost_Returns400 sends image URLs whose host no URL
|
||||||
|
// host can be, and checks that each is refused with 400 naming the host as
|
||||||
|
// decoded: allowlistedHost with a character after it that a URL host cannot
|
||||||
|
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
||||||
|
// around something that is not an IPv6 address. A "?" or "#" after
|
||||||
|
// allowlistedHost would otherwise end the host when the source URL is read,
|
||||||
|
// so the request would pass the allowlist and be fetched and cached under a
|
||||||
|
// host that is not the one fetched.
|
||||||
|
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
photos := newPhotoFetcher(t, allowlistedHost)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writtenHost string
|
||||||
|
decodedHost string
|
||||||
|
}{
|
||||||
|
{"? written as %3F", allowlistedHost + "%3Fx=1", allowlistedHost + "?x=1"},
|
||||||
|
{"# written as %23", allowlistedHost + "%23x", allowlistedHost + "#x"},
|
||||||
|
{"% written as %25", allowlistedHost + "%253F", allowlistedHost + "%3F"},
|
||||||
|
{`\ written as %5C`, allowlistedHost + "%5Cx", allowlistedHost + `\x`},
|
||||||
|
{"space written as %20", allowlistedHost + "%20", allowlistedHost + " "},
|
||||||
|
{"IPv6 address without brackets", "2001:db8::1", "2001:db8::1"},
|
||||||
|
{"IPv4 address in brackets", "[203.0.113.5]", "[203.0.113.5]"},
|
||||||
|
{"no address in brackets", "[abc]", "[abc]"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := sendGet(t, newImageRoute(t, photos), photoURL(tt.writtenHost))
|
||||||
|
checkErrorBody(t, rec, http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("invalid image URL: invalid source host: %q",
|
||||||
|
tt.decodedHost))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_HostForms_Served checks that the image route serves, with
|
||||||
|
// a valid signature, the image at photoPath on a host name, an IPv4 address,
|
||||||
|
// an IPv6 address in brackets, and a host followed by ":" with or without a
|
||||||
|
// port.
|
||||||
|
func TestHandleImage_HostForms_Served(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hosts := []string{
|
||||||
|
allowlistedHost,
|
||||||
|
"img_1.example.com",
|
||||||
|
"203.0.113.5",
|
||||||
|
"[2001:db8::1]",
|
||||||
|
allowlistedHost + ":8443",
|
||||||
|
"[2001:db8::1]:8443",
|
||||||
|
allowlistedHost + ":",
|
||||||
|
}
|
||||||
|
|
||||||
|
photos := newPhotoFetcher(t, hosts...)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sig := photoSignature(testSigningKey, host, exp)
|
||||||
|
|
||||||
|
rec := sendGet(t, newImageRoute(t, photos),
|
||||||
|
photoURLWithSig(host, sig, exp))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d: %s",
|
||||||
|
rec.Code, http.StatusOK, rec.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
||||||
|
// source path or source query holds a "#", written as %23, and checks that
|
||||||
|
// each is refused with 400, the one signed for that source path included. The
|
||||||
|
// source URL would otherwise end at the "#" when it is fetched, so each text
|
||||||
|
// after it would fetch the image at photoPath and cache it under keys of its
|
||||||
|
// own.
|
||||||
|
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath + "#x",
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
Expires: exp,
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
}{
|
||||||
|
{"# in the source path",
|
||||||
|
"/v1/image/" + allowlistedHost + photoPath + "%23x/50x50.jpeg"},
|
||||||
|
{"# in the source query",
|
||||||
|
"/v1/image/" + allowlistedHost + photoPath + "%3Fv=1%23x/50x50.jpeg"},
|
||||||
|
{"# in the source path, signed",
|
||||||
|
fmt.Sprintf("/v1/image/%s%s%%23x/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, photoPath, sig, exp.Unix())},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
||||||
|
checkErrorBody(t, rec, http.StatusBadRequest,
|
||||||
|
`invalid image URL: invalid image path: `+
|
||||||
|
`the source path or query holds a "#"`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,15 +44,18 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/images/photo.jpg",
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: "/images/photo.jpg",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
Quality: 85,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJPEG,
|
||||||
Expires: expires,
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
Expires: expires,
|
||||||
})
|
})
|
||||||
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
||||||
signedHost, sig, expires.Unix())
|
signedHost, sig, expires.Unix())
|
||||||
@@ -88,9 +91,9 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
quality int
|
quality int
|
||||||
fitMode imgcache.FitMode
|
fitMode imagerequest.Fit
|
||||||
}{
|
}{
|
||||||
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
{"quality 40 and fit contain", 40, imagerequest.FitContain},
|
||||||
{"quality and fit unset", 0, ""},
|
{"quality and fit unset", 0, ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,13 +103,17 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/images/photo.jpg",
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: "/images/photo.jpg",
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
Quality: tt.quality,
|
Transform: imagerequest.Transform{
|
||||||
FitMode: tt.fitMode,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: tt.quality,
|
||||||
|
FitMode: tt.fitMode,
|
||||||
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
@@ -169,3 +176,180 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
||||||
|
// by the service's signed-URL generator for source URLs with a query to the
|
||||||
|
// route, through a chi router: chi routes on the path as the client wrote it
|
||||||
|
// whenever that is not Go's own encoding of it. A 502 means the signature
|
||||||
|
// passed, so the route read the source path and query that were signed.
|
||||||
|
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{"slash and colon", "url=https://origin.example.com/cat.jpg"},
|
||||||
|
{"semicolon", "a=1;b=2"},
|
||||||
|
{"comma", "crop=0,0,50,50"},
|
||||||
|
{"equals and ampersand", "a=1&b=2"},
|
||||||
|
{"percent-escape", "name=my%20cat"},
|
||||||
|
{"question mark", "next=/a?b=1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
Query: tt.query,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getImage(t, fix, signedURL)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SourceQueryEncodedAnyWay signs one source URL with a query
|
||||||
|
// and writes it into the image URL in ways the README allows: the "?" before
|
||||||
|
// the query and each "%" in it percent-encoded, and every other character as
|
||||||
|
// itself or percent-encoded. A 502 means the signature passed.
|
||||||
|
func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
Query: "url=https://origin.example.com/my%20cat.jpg",
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writtenQuery string
|
||||||
|
}{
|
||||||
|
{"only ? and % encoded",
|
||||||
|
"%3Furl=https://origin.example.com/my%2520cat.jpg"},
|
||||||
|
{"every reserved character encoded",
|
||||||
|
"%3Furl%3Dhttps%3A%2F%2Forigin.example.com%2Fmy%2520cat.jpg"},
|
||||||
|
{"lower-case hex digits",
|
||||||
|
"%3furl=https://origin.example.com/my%2520cat.jpg"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
target := fmt.Sprintf("/v1/image/%s%s%s/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, photoPath, tt.writtenQuery, sig, expires.Unix())
|
||||||
|
|
||||||
|
status := getImage(t, setupTestHandler(t), target)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
||||||
|
// source query that each hold %25, the percent-escape of a literal "%", and
|
||||||
|
// sends each to the route written as the README says, with that "%" written as
|
||||||
|
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed.
|
||||||
|
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sourcePath string
|
||||||
|
sourceQuery string
|
||||||
|
// writtenPathAndQuery is the source path and query as the README says
|
||||||
|
// to write them into the image URL
|
||||||
|
writtenPathAndQuery string
|
||||||
|
}{
|
||||||
|
{"path", "/sale/50%25off.jpg", "", "/sale/50%2525off.jpg"},
|
||||||
|
{"query", photoPath, "discount=50%25", photoPath + "%3Fdiscount=50%2525"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+" written by the README rule", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: tt.sourcePath,
|
||||||
|
Query: tt.sourceQuery,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
|
||||||
|
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
||||||
|
|
||||||
|
status := getImage(t, setupTestHandler(t), target)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: tt.sourcePath,
|
||||||
|
Query: tt.sourceQuery,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getImage(t, fix, signedURL)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
||||||
@@ -30,16 +32,17 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
s.log.Debug("encrypted image request",
|
s.log.Debug("encrypted image request",
|
||||||
"host", req.SourceHost,
|
"host", req.Source.Host,
|
||||||
"path", req.SourcePath,
|
"path", req.Source.Path,
|
||||||
"dimensions", fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height),
|
"dimensions", fmt.Sprintf("%dx%d",
|
||||||
"format", req.Format,
|
req.Transform.Size.Width, req.Transform.Size.Height),
|
||||||
|
"format", req.Transform.Format,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch and process the image (no signature validation
|
// Fetch and process the image (no signature validation
|
||||||
@@ -87,10 +90,10 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
s.log.Info("image served",
|
s.log.Info("image served",
|
||||||
"request_id", middleware.GetReqID(ctx),
|
"request_id", middleware.GetReqID(ctx),
|
||||||
"cache_key", imgcache.CacheKey(req),
|
"cache_key", imagerequest.CacheKey(req),
|
||||||
"host", req.SourceHost,
|
"host", req.Source.Host,
|
||||||
"path", req.SourcePath,
|
"path", req.Source.Path,
|
||||||
"format", req.Format,
|
"format", req.Transform.Format,
|
||||||
"cache_status", resp.CacheStatus,
|
"cache_status", resp.CacheStatus,
|
||||||
"served_bytes", written,
|
"served_bytes", written,
|
||||||
"duration_ms", duration.Milliseconds(),
|
"duration_ms", duration.Milliseconds(),
|
||||||
@@ -99,12 +102,12 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
||||||
// ImageRequest and checks it. On a token that is missing, does not decrypt,
|
// image request and checks it. On a token that is missing, does not decrypt,
|
||||||
// has expired or asks for something not valid, it writes an error response
|
// has expired or asks for something not valid, it writes an error response
|
||||||
// and returns false.
|
// and returns false.
|
||||||
func (s *Handlers) parseImageEncRequest(
|
func (s *Handlers) parseImageEncRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imgcache.ImageRequest, bool) {
|
) (*imagerequest.Request, bool) {
|
||||||
// Extract token from URL
|
// Extract token from URL
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -114,7 +117,7 @@ func (s *Handlers) parseImageEncRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and validate the payload
|
// Decrypt and validate the payload
|
||||||
payload, err := s.encGen.Parse(token)
|
req, err := s.encGen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, encurl.ErrExpired) {
|
if errors.Is(err, encurl.ErrExpired) {
|
||||||
s.log.Debug("encrypted URL expired", "error", err)
|
s.log.Debug("encrypted URL expired", "error", err)
|
||||||
@@ -129,14 +132,11 @@ func (s *Handlers) parseImageEncRequest(
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert payload to ImageRequest
|
|
||||||
req := payload.ToImageRequest()
|
|
||||||
|
|
||||||
// Apply the same dimension and fit-mode bounds as the plain image
|
// Apply the same dimension and fit-mode bounds as the plain image
|
||||||
// route: a sealed payload is trusted for its origin, not for staying
|
// route: a sealed payload is trusted for its origin, not for staying
|
||||||
// within limits, so an over-limit size or unknown fit mode is a 400
|
// within limits, so an over-limit size or unknown fit mode is a 400
|
||||||
// here rather than an out-of-memory or a 500 from the processor.
|
// here rather than an out-of-memory or a 500 from the processor.
|
||||||
err = imgcache.ValidateImageRequest(req)
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Debug("encrypted URL failed validation", "error", err)
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
@@ -158,9 +158,25 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
|||||||
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
||||||
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages):
|
errors.Is(err, imageprocessor.ErrTooManyImages),
|
||||||
|
errors.Is(err, imgcache.ErrProcessingTimedOut):
|
||||||
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),
|
||||||
|
errors.Is(err, magic.ErrMagicByteMismatch),
|
||||||
|
errors.Is(err, magic.ErrUnknownFormat),
|
||||||
|
errors.Is(err, magic.ErrNotEnoughData):
|
||||||
|
s.respondError(w, "unsupported source format",
|
||||||
|
http.StatusUnsupportedMediaType)
|
||||||
|
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
||||||
|
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
||||||
|
"computed from its proportions is above %d",
|
||||||
|
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
||||||
|
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
|
||||||
|
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
||||||
|
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
||||||
|
http.StatusBadRequest)
|
||||||
default:
|
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)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newEncTestServer builds a router serving the encrypted-URL route with a
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||||
@@ -54,11 +54,14 @@ func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: "cdn.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/photo.jpg",
|
Host: "cdn.example.com",
|
||||||
Width: 100000,
|
Path: "/photo.jpg",
|
||||||
Height: 100000,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 100000, Height: 100000},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
@@ -79,12 +82,15 @@ func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: "cdn.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/photo.jpg",
|
Host: "cdn.example.com",
|
||||||
Width: 800,
|
Path: "/photo.jpg",
|
||||||
Height: 600,
|
},
|
||||||
FitMode: imgcache.FitMode("bogus"),
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
|
FitMode: imagerequest.Fit("bogus"),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
||||||
@@ -59,11 +60,14 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
photo := encurl.Payload{
|
photo := imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := h.encGen.Generate(&photo)
|
valid, err := h.encGen.Generate(&photo)
|
||||||
@@ -72,7 +76,7 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expiredPhoto := photo
|
expiredPhoto := photo
|
||||||
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
|
expiredPhoto.Expires = time.Now().Add(-time.Minute)
|
||||||
|
|
||||||
expired, err := h.encGen.Generate(&expiredPhoto)
|
expired, err := h.encGen.Generate(&expiredPhoto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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/imagerequest"
|
||||||
|
"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(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJXL,
|
||||||
|
Quality: encurl.DefaultQuality,
|
||||||
|
FitMode: imagerequest.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(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{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/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 imagerequest.Format
|
||||||
|
}{
|
||||||
|
{"JPEG XL-capable browser",
|
||||||
|
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imagerequest.FormatJXL},
|
||||||
|
{"JPEG XL only", jxlType, imagerequest.FormatJXL},
|
||||||
|
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
||||||
|
imagerequest.FormatJXL},
|
||||||
|
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imagerequest.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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,6 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
|
|||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
||||||
@@ -22,11 +21,15 @@ import (
|
|||||||
func signedPhotoURL(t *testing.T, h *Handlers) string {
|
func signedPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
@@ -40,12 +43,15 @@ func signedPhotoURL(t *testing.T, h *Handlers) string {
|
|||||||
func encPhotoURL(t *testing.T, h *Handlers) string {
|
func encPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +22,9 @@ func TestServerBusyAnswers503(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.jpg"},
|
||||||
|
}
|
||||||
|
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
||||||
@@ -47,3 +51,40 @@ func TestServerBusyAnswers503(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessingTimedOutAnswers503 checks that respondImageError and
|
||||||
|
// handleImageError, which answer the errors of the two image routes, answer
|
||||||
|
// 503 with the server busy error when the image service reports that
|
||||||
|
// processing stopped at the deadline of the request doing it, wrapped as the
|
||||||
|
// service wraps it. A request that waited for that processing gets this error
|
||||||
|
// while its own deadline may have time left. The request's source only goes
|
||||||
|
// into a log line, so it is left empty.
|
||||||
|
func TestProcessingTimedOutAnswers503(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
err := fmt.Errorf("%w: failed to encode: %w",
|
||||||
|
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
|
||||||
|
|
||||||
|
plain := httptest.NewRecorder()
|
||||||
|
h.respondImageError(plain, &imagerequest.Request{}, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"respondImageError": plain, "handleImageError": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("%s: status = %d, want %d",
|
||||||
|
name, rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
|
t.Errorf("%s: body = %q, want the server busy error",
|
||||||
|
name, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "img.example.com", Path: "/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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -297,6 +298,139 @@ func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchWithMaxResponseSize serves handler as the fake upstream and fetches
|
||||||
|
// its /image route with MaxResponseSize set to maxResponseSize.
|
||||||
|
func fetchWithMaxResponseSize(
|
||||||
|
t *testing.T,
|
||||||
|
maxResponseSize int64,
|
||||||
|
handler http.HandlerFunc,
|
||||||
|
) *FetchResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxResponseSize = maxResponseSize
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
t.Cleanup(func() { _ = res.Content.Close() })
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchContentLengthBodyAtMaxResponseSize checks that a body sent with a
|
||||||
|
// Content-Length is read whole at exactly MaxResponseSize bytes and refused
|
||||||
|
// at one byte more.
|
||||||
|
func TestFetchContentLengthBodyAtMaxResponseSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const maxResponseSize = 8
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
size int
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{maxResponseSize, nil},
|
||||||
|
{maxResponseSize + 1, ErrResponseTooLarge},
|
||||||
|
} {
|
||||||
|
t.Run(strconv.Itoa(tc.size)+" bytes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := strings.Repeat("x", tc.size)
|
||||||
|
|
||||||
|
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(tc.size))
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
})
|
||||||
|
|
||||||
|
if res.ContentLength != int64(tc.size) {
|
||||||
|
t.Fatalf("ContentLength = %d, want %d", res.ContentLength, tc.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(res.Content)
|
||||||
|
t.Logf("body of %d bytes: read %d, error %v", tc.size, len(data), err)
|
||||||
|
|
||||||
|
if !errors.Is(err, tc.wantErr) {
|
||||||
|
t.Fatalf("read error = %v, want %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && string(data) != body {
|
||||||
|
t.Errorf("read %q, want %q", data, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchChunkedBodyAtMaxResponseSize checks that a chunked body is read
|
||||||
|
// whole at exactly MaxResponseSize bytes and refused at one byte more. The
|
||||||
|
// upstream sends the end of the body only once the test has read
|
||||||
|
// MaxResponseSize bytes, so the read after them comes before the end of the
|
||||||
|
// body has arrived, as it can from a real upstream.
|
||||||
|
func TestFetchChunkedBodyAtMaxResponseSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const maxResponseSize = 8
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
size int
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{maxResponseSize, nil},
|
||||||
|
{maxResponseSize + 1, ErrResponseTooLarge},
|
||||||
|
} {
|
||||||
|
t.Run(strconv.Itoa(tc.size)+" bytes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := strings.Repeat("x", tc.size)
|
||||||
|
endBody := make(chan struct{})
|
||||||
|
|
||||||
|
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
// With no Content-Length, flushing sends the body
|
||||||
|
// chunked; its end is sent when the handler returns.
|
||||||
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-endBody:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if res.ContentLength != -1 {
|
||||||
|
t.Fatalf("ContentLength = %d, want -1 (chunked)", res.ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
head := make([]byte, maxResponseSize)
|
||||||
|
|
||||||
|
_, err := io.ReadFull(res.Content, head)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the first %d bytes: %v", maxResponseSize, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(endBody)
|
||||||
|
|
||||||
|
rest, err := io.ReadAll(res.Content)
|
||||||
|
data := string(head) + string(rest)
|
||||||
|
t.Logf("body of %d bytes: read %d, error %v", tc.size, len(data), err)
|
||||||
|
|
||||||
|
if !errors.Is(err, tc.wantErr) {
|
||||||
|
t.Fatalf("read error = %v, want %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && data != body {
|
||||||
|
t.Errorf("read %q, want %q", data, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -58,15 +59,17 @@ const (
|
|||||||
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
||||||
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
||||||
// already cover. They are always blocked, in addition to any
|
// already cover. They are always blocked, in addition to any
|
||||||
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
|
// operator-supplied networks. IPv4-mapped and IPv4-compatible IPv6
|
||||||
// before matching, so these IPv4 ranges are caught in both forms.
|
// addresses are read as the IPv4 address they hold before matching, so
|
||||||
|
// these IPv4 ranges are caught in all three forms.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // immutable built-in blocklist
|
//nolint:gochecknoglobals // immutable built-in blocklist
|
||||||
var builtinBlockedPrefixes = []netip.Prefix{
|
var builtinBlockedPrefixes = []netip.Prefix{
|
||||||
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
||||||
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
||||||
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
||||||
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
||||||
|
netip.MustParsePrefix("64:ff9b:1::/48"), // RFC 8215 local-use NAT64 (maps onto IPv4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetcher errors.
|
// Fetcher errors.
|
||||||
@@ -156,7 +159,7 @@ func DefaultConfig() *Config {
|
|||||||
contentTypeGIF,
|
contentTypeGIF,
|
||||||
contentTypeWebP,
|
contentTypeWebP,
|
||||||
contentTypeAVIF,
|
contentTypeAVIF,
|
||||||
contentTypeSVG,
|
contentTypeJXL,
|
||||||
},
|
},
|
||||||
AllowHTTP: false,
|
AllowHTTP: false,
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
@@ -472,16 +475,16 @@ func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
|||||||
return ErrUnsupportedScheme
|
return ErrUnsupportedScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse to extract host
|
parsedURL, err := neturl.Parse(rawURL)
|
||||||
host := extractHost(rawURL)
|
if err != nil {
|
||||||
if host == "" {
|
return fmt.Errorf("%w: %w", ErrInvalidHost, err)
|
||||||
return ErrInvalidHost
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove port if present
|
// Hostname drops any port, and the brackets of an IPv6 address whether
|
||||||
h, _, err := net.SplitHostPort(host)
|
// or not a port follows them
|
||||||
if err == nil {
|
host := parsedURL.Hostname()
|
||||||
host = h
|
if host == "" {
|
||||||
|
return ErrInvalidHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block obvious localhost patterns
|
// Block obvious localhost patterns
|
||||||
@@ -542,6 +545,9 @@ func isPrivateIP(ip net.IP) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An IPv4-compatible IPv6 address is judged as the IPv4 address it holds
|
||||||
|
ip = ipv4CompatibleToIPv4(ip)
|
||||||
|
|
||||||
// Check for loopback
|
// Check for loopback
|
||||||
if ip.IsLoopback() {
|
if ip.IsLoopback() {
|
||||||
return true
|
return true
|
||||||
@@ -593,7 +599,12 @@ func isPrivateIP(ip net.IP) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isBlockedIP reports whether ip is refused, either by the built-in
|
// isBlockedIP reports whether ip is refused, either by the built-in
|
||||||
// internal-range check or by one of the operator-supplied prefixes.
|
// internal-range check or by one of the operator-supplied prefixes. An
|
||||||
|
// IPv4-compatible address (in ::/96) is refused when a prefix contains the
|
||||||
|
// address as written or the IPv4 address it holds. An IPv4-mapped address (in
|
||||||
|
// ::ffff:0:0/96) is matched only as the IPv4 address it holds: Go keeps a
|
||||||
|
// plain IPv4 address in that form too, and an IPv6 prefix such as ::/0 must
|
||||||
|
// not refuse every IPv4 address.
|
||||||
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
||||||
if isPrivateIP(ip) {
|
if isPrivateIP(ip) {
|
||||||
return true
|
return true
|
||||||
@@ -606,11 +617,28 @@ func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
|||||||
|
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
// ip read as IPv4 where it is or holds an IPv4 address, otherwise the
|
||||||
|
// zero netip.Addr, which no prefix contains. Matching asIPv4 as well as
|
||||||
|
// addr refuses more only for an IPv4-compatible address.
|
||||||
|
asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4())
|
||||||
|
|
||||||
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
||||||
return prefix.Contains(addr)
|
return prefix.Contains(addr) || prefix.Contains(asIPv4)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6
|
||||||
|
// address (in ::/96, such as ::127.0.0.1) holds in its last four bytes, and
|
||||||
|
// any other address unchanged.
|
||||||
|
func ipv4CompatibleToIPv4(ip net.IP) net.IP {
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if ok && netip.MustParsePrefix("::/96").Contains(addr) {
|
||||||
|
return ip[12:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
||||||
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
||||||
// blocklist; this entry point enforces the built-in ranges alone.
|
// blocklist; this entry point enforces the built-in ranges alone.
|
||||||
@@ -659,14 +687,33 @@ func dialSSRFSafe(
|
|||||||
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitedReader wraps a reader and limits the number of bytes read.
|
// limitedReader wraps a reader and refuses a body longer than remaining
|
||||||
|
// bytes with ErrResponseTooLarge. remaining goes below 0 once the body is
|
||||||
|
// found to be longer.
|
||||||
type limitedReader struct {
|
type limitedReader struct {
|
||||||
reader io.Reader
|
reader io.Reader
|
||||||
remaining int64
|
remaining int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read reads at most the remaining bytes. Once they are read, it reads one
|
||||||
|
// more byte to tell the end of the body from more data: at the end it
|
||||||
|
// returns the wrapped reader's error, io.EOF; with more data it returns
|
||||||
|
// ErrResponseTooLarge, as does every later Read.
|
||||||
func (r *limitedReader) Read(p []byte) (int, error) {
|
func (r *limitedReader) Read(p []byte) (int, error) {
|
||||||
if r.remaining <= 0 {
|
if r.remaining < 0 {
|
||||||
|
return 0, ErrResponseTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.remaining == 0 {
|
||||||
|
var extra [1]byte
|
||||||
|
|
||||||
|
n, err := r.reader.Read(extra[:])
|
||||||
|
r.remaining -= int64(n)
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
return 0, ErrResponseTooLarge
|
return 0, ErrResponseTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,44 @@ func TestIsPrivateIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsPrivateIPJudgesEmbeddedIPv4Address checks IPv6 addresses that hold an
|
||||||
|
// IPv4 address in their last four bytes: the IPv4-compatible form (::/96) and
|
||||||
|
// the NAT64 form (64:ff9b::/96). One holding a loopback, private or
|
||||||
|
// link-local IPv4 address is refused as that address is, and one holding a
|
||||||
|
// public IPv4 address in the IPv4-compatible form passes.
|
||||||
|
func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"::127.0.0.1", true}, // loopback
|
||||||
|
{"::10.0.0.1", true}, // private
|
||||||
|
{"::169.254.169.254", true}, // link-local
|
||||||
|
{"64:ff9b::7f00:1", true}, // NAT64 form of 127.0.0.1
|
||||||
|
{"::8.8.8.8", false}, // public
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.ip, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
t.Logf("isPrivateIP(%s) = %v", ip, got)
|
||||||
|
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -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,94 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
|
||||||
|
// of more than MaxAVIFOutputPixels is refused with ErrAVIFOutputTooLarge
|
||||||
|
// before anything is decoded, whatever size and fit give it, the source's own
|
||||||
|
// size included, and that an AVIF output of at most that many pixels, or one
|
||||||
|
// in another format, is not. One input is a 64x32 PNG cut off in its pixel
|
||||||
|
// data. The other is testdata/red.avif, a 4x4 AVIF, with the size in its
|
||||||
|
// header changed to 4097x4096. libvips reads each input's size from its
|
||||||
|
// header but fails when it decodes its pixel data, so a request that gets
|
||||||
|
// that failure was not refused, and a refusal came before any decoding.
|
||||||
|
func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
whole := createTestPNG(t, 64, 32)
|
||||||
|
cutOff := whole[:len(whole)/2]
|
||||||
|
|
||||||
|
largeAVIF, err := os.ReadFile("testdata/red.avif")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test AVIF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ispe box gives the width and then the height, 4 bytes each, after
|
||||||
|
// the box's type and its 4 bytes of version and flags
|
||||||
|
ispe := bytes.Index(largeAVIF, []byte("ispe"))
|
||||||
|
binary.BigEndian.PutUint32(largeAVIF[ispe+8:], 4097)
|
||||||
|
binary.BigEndian.PutUint32(largeAVIF[ispe+12:], 4096)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
format imagerequest.Format
|
||||||
|
width, height int
|
||||||
|
fit imagerequest.Fit
|
||||||
|
refused bool
|
||||||
|
}{
|
||||||
|
{"AVIF at the limit", cutOff, imagerequest.FormatAVIF,
|
||||||
|
4096, 4096, imagerequest.FitCover, false},
|
||||||
|
{"AVIF above the limit", cutOff, imagerequest.FormatAVIF,
|
||||||
|
4096, 4097, imagerequest.FitCover, true},
|
||||||
|
{"WebP above the AVIF limit", cutOff, imagerequest.FormatWebP,
|
||||||
|
4096, 4097, imagerequest.FitCover, false},
|
||||||
|
// FitInside does not enlarge, so the output stays 64x32
|
||||||
|
{"AVIF inside a larger size", cutOff, imagerequest.FormatAVIF,
|
||||||
|
8192, 8192, imagerequest.FitInside, false},
|
||||||
|
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
|
||||||
|
{"AVIF outside a smaller size", cutOff, imagerequest.FormatAVIF,
|
||||||
|
8192, 1, imagerequest.FitOutside, true},
|
||||||
|
// With no size, the output keeps the source's 4097x4096
|
||||||
|
{"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
||||||
|
0, 0, imagerequest.FitCover, true},
|
||||||
|
{"orig of an AVIF source", largeAVIF, imagerequest.FormatOriginal,
|
||||||
|
0, 0, imagerequest.FitCover, true},
|
||||||
|
{"JPEG of the source's size", largeAVIF, imagerequest.FormatJPEG,
|
||||||
|
0, 0, imagerequest.FitCover, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := New(Params{}).Process(t.Context(), bytes.NewReader(tt.input),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
|
||||||
|
Format: tt.format,
|
||||||
|
FitMode: tt.fit,
|
||||||
|
})
|
||||||
|
t.Logf("Process() error = %v", err)
|
||||||
|
|
||||||
|
if tt.refused {
|
||||||
|
if !errors.Is(err, ErrAVIFOutputTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, ErrAVIFOutputTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil || errors.Is(err, ErrAVIFOutputTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want the failure to decode "+
|
||||||
|
"the input", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 imagerequest.Size
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"width at the maximum", 4096, 1,
|
||||||
|
imagerequest.Size{Height: 2}, nil},
|
||||||
|
{"width above the maximum", 4096, 1,
|
||||||
|
imagerequest.Size{Height: 3}, ErrOutputTooLarge},
|
||||||
|
{"height at the maximum", 1, 4096,
|
||||||
|
imagerequest.Size{Width: 2}, nil},
|
||||||
|
{"height above the maximum", 1, 4096,
|
||||||
|
imagerequest.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),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_ComputedSideIsAtLeastOne verifies that, for a size with
|
||||||
|
// one side 0, the side computed from the input's proportions is 1 where it
|
||||||
|
// would round down to 0, and that Process makes that size in every fit mode
|
||||||
|
// but FitOutside, which scales the input to cover the size and so can make
|
||||||
|
// it longer. Each case checks the size targetDimensions gives before it
|
||||||
|
// calls Process: with that side at 0, an 8192x1 input at 1300x0 makes
|
||||||
|
// libvips ask for about 39 GB with FitCover.
|
||||||
|
func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputWidth, inputHeight int
|
||||||
|
size imagerequest.Size
|
||||||
|
wantWidth, wantHeight int
|
||||||
|
}{
|
||||||
|
{"8192x1 at 100x0", 8192, 1, imagerequest.Size{Width: 100}, 100, 1},
|
||||||
|
{"8192x1 at 1300x0", 8192, 1, imagerequest.Size{Width: 1300}, 1300, 1},
|
||||||
|
{"8192x2 at 100x0", 8192, 2, imagerequest.Size{Width: 100}, 100, 1},
|
||||||
|
{"1x8192 at 0x100", 1, 8192, imagerequest.Size{Height: 100}, 1, 100},
|
||||||
|
{"1x8192 at 0x1300", 1, 8192, imagerequest.Size{Height: 1300}, 1, 1300},
|
||||||
|
{"2x8192 at 0x100", 2, 8192, imagerequest.Size{Height: 100}, 1, 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
fits := []imagerequest.Fit{
|
||||||
|
imagerequest.FitCover, imagerequest.FitContain, imagerequest.FitFill,
|
||||||
|
imagerequest.FitInside, imagerequest.FitOutside,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for _, fit := range fits {
|
||||||
|
t.Run(tt.name+" "+string(fit), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
width, height, err := targetDimensions(tt.size,
|
||||||
|
tt.inputWidth, tt.inputHeight)
|
||||||
|
if err != nil || width != tt.wantWidth || height != tt.wantHeight {
|
||||||
|
t.Fatalf("targetDimensions() = %dx%d, %v, want %dx%d",
|
||||||
|
width, height, err, tt.wantWidth, tt.wantHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: fit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
|
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
|
||||||
|
if fit == imagerequest.FitOutside {
|
||||||
|
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
|
||||||
|
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
|
||||||
|
imagerequest.FitOutside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readerAfterDeadline is a Process input whose first Read waits until done
|
||||||
|
// is closed, so the deadline passes after Process has taken its slot and
|
||||||
|
// before it encodes.
|
||||||
|
type readerAfterDeadline struct {
|
||||||
|
data *bytes.Reader
|
||||||
|
done <-chan struct{}
|
||||||
|
waited bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerAfterDeadline) Read(p []byte) (int, error) {
|
||||||
|
if !r.waited {
|
||||||
|
r.waited = true
|
||||||
|
|
||||||
|
<-r.done
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.data.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputNotToBeRead is a Process input that fails the test when it is read.
|
||||||
|
type inputNotToBeRead struct {
|
||||||
|
t *testing.T
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r inputNotToBeRead) Read([]byte) (int, error) {
|
||||||
|
r.t.Error("Process read its input although its deadline had passed")
|
||||||
|
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessStopsWhenDeadlinePassed checks that Process returns
|
||||||
|
// context.DeadlineExceeded, and holds no slot afterwards, when its context's
|
||||||
|
// deadline has passed before it starts, then without reading its input, or
|
||||||
|
// while it reads its input. govips gives pixa no way to stop libvips once an
|
||||||
|
// encode has begun, so Process must not begin one for a request that has
|
||||||
|
// ended.
|
||||||
|
func TestProcessStopsWhenDeadlinePassed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 50, 50)
|
||||||
|
|
||||||
|
t.Run("before Process starts", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithDeadline(t.Context(),
|
||||||
|
time.Now().Add(-time.Second))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// With a slot free, the select in acquireSlot takes the slot or sees
|
||||||
|
// the ended context at random, so Process is called many times
|
||||||
|
for range 100 {
|
||||||
|
checkProcessStopsAtDeadline(ctx, t, inputNotToBeRead{t: t})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("while Process reads its input", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
checkProcessStopsAtDeadline(ctx, t, &readerAfterDeadline{
|
||||||
|
data: bytes.NewReader(input), done: ctx.Done(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkProcessStopsAtDeadline runs Process on input with ctx, whose deadline
|
||||||
|
// has passed by the time Process could encode, and fails the test unless
|
||||||
|
// Process returns context.DeadlineExceeded and gives its slot back.
|
||||||
|
func checkProcessStopsAtDeadline(
|
||||||
|
ctx context.Context, t *testing.T, input io.Reader,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, input, smallJPEGTransform())
|
||||||
|
if result != nil {
|
||||||
|
t.Logf("Process() made a %dx%d image after its deadline passed",
|
||||||
|
result.Width, result.Height)
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Process() error = %v", err)
|
||||||
|
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("Process() error = %v, want context.DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(proc.processingSemaphore); held != 0 {
|
||||||
|
t.Errorf("%d slots still held after Process() returned", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
&imagerequest.Transform{},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processedSize runs input through Process and returns the output's size in
|
||||||
|
// bytes.
|
||||||
|
func processedSize(
|
||||||
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), transform)
|
||||||
|
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),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
|
const rawBytes = width * height * 3
|
||||||
|
if size > rawBytes/10 {
|
||||||
|
t.Errorf("PNG output is %d bytes, want under a tenth of its %d raw",
|
||||||
|
size, rawBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_WebPAtEffort2 verifies that WebP is saved at effort 2,
|
||||||
|
// not libvips' default of 4: the output is the size of the same image saved
|
||||||
|
// at effort 2.
|
||||||
|
func TestImageProcessor_WebPAtEffort2(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
size := processedSize(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
ReductionEffort: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportWebp() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("WebP output is %d bytes, want %d, the size at effort 2",
|
||||||
|
size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_AVIFAtEffort1 verifies that AVIF is saved at effort 1
|
||||||
|
// with 8 bits per sample: the output is the size of the same image saved
|
||||||
|
// with those settings. The source is 16-bit, which libvips saves with 12
|
||||||
|
// bits when it is not given a bit depth, and 640x480: on a small image,
|
||||||
|
// such as 64x48, efforts 1 and 2 give the same output.
|
||||||
|
func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 640, 480
|
||||||
|
|
||||||
|
source := image.NewRGBA64(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
source.Set(x, y, color.RGBA64{
|
||||||
|
R: uint16((x * 65535 / width) & 0xffff),
|
||||||
|
G: uint16((y * 65535 / height) & 0xffff),
|
||||||
|
B: 32768,
|
||||||
|
A: 65535,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
|
size := processedSize(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
Effort: 1,
|
||||||
|
Bitdepth: 8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportAvif() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("AVIF output is %d bytes, want %d, the size at effort 1 "+
|
||||||
|
"and 8 bits", size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLAtEffort4 verifies that JPEG XL is saved at
|
||||||
|
// effort 4, not libvips' default of 7: the output is the size of the same
|
||||||
|
// image saved at effort 4, with the distance exportJXL gives quality 85.
|
||||||
|
func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
size := processedSize(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
|
||||||
|
|
||||||
|
params := vips.NewJxlExportParams()
|
||||||
|
params.Quality = 85
|
||||||
|
params.Distance = jxlDistance(85)
|
||||||
|
params.Effort = 4
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportJxl(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportJxl() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("JPEG XL output is %d bytes, want %d, the size at effort 4",
|
||||||
|
size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
|
||||||
|
// a thin source at the requested size. An 8192x1 source scaled to cover
|
||||||
|
// 8192x8192 is 8192 times as long as the output on its long side. The centre
|
||||||
|
// of an 8192x1 source with the proportions of 1x8192 is 1/8192 of a pixel
|
||||||
|
// wide. Each case is also turned on its side.
|
||||||
|
func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputWidth, inputHeight int
|
||||||
|
size imagerequest.Size
|
||||||
|
}{
|
||||||
|
{"8192x1 at 8192x8192", 8192, 1, imagerequest.Size{Width: 8192, Height: 8192}},
|
||||||
|
{"8192x1 at 1x8192", 8192, 1, imagerequest.Size{Width: 1, Height: 8192}},
|
||||||
|
{"1x8192 at 8192x8192", 1, 8192, imagerequest.Size{Width: 8192, Height: 8192}},
|
||||||
|
{"1x8192 at 8192x1", 1, 8192, imagerequest.Size{Width: 8192, Height: 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
|
if result.Width != tt.size.Width || result.Height != tt.size.Height {
|
||||||
|
t.Errorf("Process() made %dx%d, want %dx%d",
|
||||||
|
result.Width, result.Height, tt.size.Width, tt.size.Height)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBandsPNG creates a PNG of three equal bands, red, green and blue,
|
||||||
|
// side by side when it is wider than tall and top to bottom otherwise.
|
||||||
|
func createBandsPNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bands := []color.RGBA{
|
||||||
|
{R: 255, A: 255},
|
||||||
|
{G: 255, A: 255},
|
||||||
|
{B: 255, A: 255},
|
||||||
|
}
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
band := 3 * y / height
|
||||||
|
if width > height {
|
||||||
|
band = 3 * x / width
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Set(x, y, bands[band])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodePNG(t, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_FitCoverCropsTheCentre verifies that FitCover keeps the
|
||||||
|
// centre of the source: a 300x100 source of red, green and blue bands side
|
||||||
|
// by side, or a 100x300 one with them top to bottom, comes out at 50x50 as
|
||||||
|
// its green band.
|
||||||
|
func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputWidth, inputHeight int
|
||||||
|
}{
|
||||||
|
{"bands side by side", 300, 100},
|
||||||
|
{"bands top to bottom", 100, 300},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatPNG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
output, err := png.Decode(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := output.Bounds()
|
||||||
|
t.Logf("Process() made %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
|
||||||
|
if bounds.Dx() != 50 || bounds.Dy() != 50 {
|
||||||
|
t.Fatalf("Process() made %dx%d, want 50x50",
|
||||||
|
bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resampling may blend a little of the bands beside the
|
||||||
|
// green one into the output's edges.
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
r, g, b, _ := output.At(x, y).RGBA()
|
||||||
|
if r>>8 > 64 || g>>8 < 192 || b>>8 > 64 {
|
||||||
|
t.Fatalf("output pixel %d,%d is %d,%d,%d, want green",
|
||||||
|
x, y, r>>8, g>>8, b>>8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The greys of createSquarePNG, and the grey halfway between them.
|
||||||
|
const (
|
||||||
|
backgroundGrey = 192
|
||||||
|
squareGrey = 64
|
||||||
|
halfwayGrey = (backgroundGrey + squareGrey) / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// createSquarePNG creates a grey PNG side pixels square with a darker grey
|
||||||
|
// square squareSide pixels wide in its centre.
|
||||||
|
func createSquarePNG(t *testing.T, side, squareSide int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
first := (side - squareSide) / 2
|
||||||
|
last := first + squareSide - 1
|
||||||
|
|
||||||
|
img := image.NewGray(image.Rect(0, 0, side, side))
|
||||||
|
for y := range side {
|
||||||
|
for x := range side {
|
||||||
|
grey := uint8(backgroundGrey)
|
||||||
|
if x >= first && x <= last && y >= first && y <= last {
|
||||||
|
grey = squareGrey
|
||||||
|
}
|
||||||
|
|
||||||
|
img.SetGray(x, y, color.Gray{Y: grey})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodePNG(t, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// squareLength returns how long the square is along greys, the greys of a
|
||||||
|
// row or column of the output that crosses it: the distance between the
|
||||||
|
// first and the last point where greys passes halfwayGrey, or 0 when there
|
||||||
|
// is no such point. Resampling blurs the square's edges over several
|
||||||
|
// pixels, and each point is placed between the two pixels on either side
|
||||||
|
// of it in proportion to their greys, so the length is measured to a
|
||||||
|
// fraction of a pixel.
|
||||||
|
func squareLength(greys []float64) float64 {
|
||||||
|
first, last := -1.0, -1.0
|
||||||
|
|
||||||
|
for i := 1; i < len(greys); i++ {
|
||||||
|
before, after := greys[i-1], greys[i]
|
||||||
|
if (before > halfwayGrey) == (after > halfwayGrey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
point := float64(i-1) + (before-halfwayGrey)/(before-after)
|
||||||
|
if first < 0 {
|
||||||
|
first = point
|
||||||
|
}
|
||||||
|
|
||||||
|
last = point
|
||||||
|
}
|
||||||
|
|
||||||
|
return last - first
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_FitCoverKeepsProportions verifies that FitCover scales
|
||||||
|
// both sides of the source by the same amount: a square in the centre of
|
||||||
|
// the source comes out square. Its width and height are measured along the
|
||||||
|
// output's middle row and column.
|
||||||
|
func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
side, squareSide int
|
||||||
|
size imagerequest.Size
|
||||||
|
}{
|
||||||
|
{"16x16 at 100x60", 16, 8, imagerequest.Size{Width: 100, Height: 60}},
|
||||||
|
{"16x16 at 60x100", 16, 8, imagerequest.Size{Width: 60, Height: 100}},
|
||||||
|
{"10x10 at 100x35", 10, 2, imagerequest.Size{Width: 100, Height: 35}},
|
||||||
|
{"10x10 at 35x100", 10, 2, imagerequest.Size{Width: 35, Height: 100}},
|
||||||
|
{"64x64 at 300x100", 64, 16, imagerequest.Size{Width: 300, Height: 100}},
|
||||||
|
{"64x64 at 100x300", 64, 16, imagerequest.Size{Width: 100, Height: 300}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createSquarePNG(t, tt.side, tt.squareSide)
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatPNG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
output, err := png.Decode(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := output.Bounds()
|
||||||
|
if bounds.Dx() != tt.size.Width || bounds.Dy() != tt.size.Height {
|
||||||
|
t.Fatalf("Process() made %dx%d, want %dx%d",
|
||||||
|
bounds.Dx(), bounds.Dy(), tt.size.Width, tt.size.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := make([]float64, bounds.Dx())
|
||||||
|
for x := range row {
|
||||||
|
r, _, _, _ := output.At(x, bounds.Dy()/2).RGBA()
|
||||||
|
row[x] = float64(r >> 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
column := make([]float64, bounds.Dy())
|
||||||
|
for y := range column {
|
||||||
|
r, _, _, _ := output.At(bounds.Dx()/2, y).RGBA()
|
||||||
|
column[y] = float64(r >> 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
width, height := squareLength(row), squareLength(column)
|
||||||
|
t.Logf("square is %.2f wide and %.2f high", width, height)
|
||||||
|
|
||||||
|
if width == 0 || math.Abs(width-height) > 0.5 {
|
||||||
|
t.Errorf("square is %.2f wide and %.2f high, want it square",
|
||||||
|
width, height)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 imagerequest.Size
|
||||||
|
// wantWidth and wantHeight are the output's, 0 for a refusal
|
||||||
|
wantWidth, wantHeight int
|
||||||
|
}{
|
||||||
|
{"width above the maximum", 3000, 600,
|
||||||
|
imagerequest.Size{Width: 1, Height: 8192}, 0, 0},
|
||||||
|
{"height above the maximum", 600, 3000,
|
||||||
|
imagerequest.Size{Width: 8192, Height: 1}, 0, 0},
|
||||||
|
{"width at the maximum", 1024, 256,
|
||||||
|
imagerequest.Size{Width: 1, Height: 2048}, 8192, 2048},
|
||||||
|
{"height at the maximum", 256, 1024,
|
||||||
|
imagerequest.Size{Width: 2048, Height: 1}, 2048, 8192},
|
||||||
|
{"within the maximum", 3000, 600,
|
||||||
|
imagerequest.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),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_FitOutsideSideIsAtLeastOne verifies that FitOutside
|
||||||
|
// keeps each side at 1 or more when it works both sides out again from the
|
||||||
|
// larger scale. A side of 49 scaled to 1 is 49 times 1/49, which in floating
|
||||||
|
// point is just under 1 and so rounded down to 0, a width libvips refuses.
|
||||||
|
func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputWidth, inputHeight int
|
||||||
|
size imagerequest.Size
|
||||||
|
wantWidth, wantHeight int
|
||||||
|
}{
|
||||||
|
{"49x8192 at 0x100", 49, 8192, imagerequest.Size{Height: 100}, 1, 167},
|
||||||
|
{"49x49 at 1x1", 49, 49, imagerequest.Size{Width: 1, Height: 1}, 1, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitOutside,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
@@ -20,11 +21,10 @@ import (
|
|||||||
var vipsOnce sync.Once
|
var vipsOnce sync.Once
|
||||||
|
|
||||||
// initVips initializes libvips with quiet logging, one worker thread per
|
// initVips initializes libvips with quiet logging, one worker thread per
|
||||||
// image and no operation cache. Process already works on one image per CPU
|
// image and no operation cache. Each request decodes different source
|
||||||
// by default, so more threads per image would only compete for the CPUs.
|
// bytes, so the operation cache would rarely be hit and would hold memory
|
||||||
// Each request decodes different source bytes, so the operation cache
|
// outside MaxConcurrentProcessing; repeated requests are served from
|
||||||
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
// pixa's disk cache instead.
|
||||||
// repeated requests are served from pixa's disk cache instead.
|
|
||||||
func initVips() {
|
func initVips() {
|
||||||
vipsOnce.Do(func() {
|
vipsOnce.Do(func() {
|
||||||
vips.LoggingSettings(nil, vips.LogLevelError)
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
||||||
@@ -37,46 +37,22 @@ func initVips() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format represents supported output image formats.
|
// errNoJPEGXL is returned by CheckJPEGXLSupport.
|
||||||
type Format string
|
var errNoJPEGXL = errors.New("libvips lacks JPEG XL support: install " +
|
||||||
|
"vips-jxl on Alpine, or use a libvips built with libjxl")
|
||||||
|
|
||||||
// Supported image output formats.
|
// CheckJPEGXLSupport returns an error, naming the fix, when libvips
|
||||||
const (
|
// cannot load and save JPEG XL.
|
||||||
FormatOriginal Format = "orig"
|
func CheckJPEGXLSupport() error {
|
||||||
FormatJPEG Format = "jpeg"
|
initVips()
|
||||||
FormatPNG Format = "png"
|
|
||||||
FormatWebP Format = "webp"
|
|
||||||
FormatAVIF Format = "avif"
|
|
||||||
FormatGIF Format = "gif"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FitMode represents how to fit an image into requested dimensions.
|
// govips counts a format as supported when libvips has its loader;
|
||||||
type FitMode string
|
// libvips builds the JPEG XL loader and saver together.
|
||||||
|
if !vips.IsTypeSupported(vips.ImageTypeJXL) {
|
||||||
|
return errNoJPEGXL
|
||||||
|
}
|
||||||
|
|
||||||
// Supported image fit modes.
|
return nil
|
||||||
const (
|
|
||||||
FitCover FitMode = "cover"
|
|
||||||
FitContain FitMode = "contain"
|
|
||||||
FitFill FitMode = "fill"
|
|
||||||
FitInside FitMode = "inside"
|
|
||||||
FitOutside FitMode = "outside"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
|
||||||
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
|
||||||
|
|
||||||
// Size represents requested image dimensions.
|
|
||||||
type Size struct {
|
|
||||||
Width int
|
|
||||||
Height int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request holds the parameters for image processing.
|
|
||||||
type Request struct {
|
|
||||||
Size Size
|
|
||||||
Format Format
|
|
||||||
Quality int
|
|
||||||
FitMode FitMode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result contains the output of image processing.
|
// Result contains the output of image processing.
|
||||||
@@ -114,10 +90,39 @@ 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 (imagerequest.MaxDimension).
|
||||||
|
const MaxOutputDimension = imagerequest.MaxDimension
|
||||||
|
|
||||||
|
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
||||||
|
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
||||||
|
// from the input's proportions, or with FitOutside, the side scaled past the
|
||||||
|
// requested size.
|
||||||
|
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
|
||||||
|
|
||||||
|
// MaxAVIFOutputPixels is the most pixels an AVIF output may have, so that an
|
||||||
|
// encode takes at most 40 seconds of the default downstream_timeout of 60
|
||||||
|
// seconds; AVIF is already at the lowest effort govips can set (avifEffort).
|
||||||
|
// With one thread, as pixad runs libvips, saving 4096x4096, 8192x2048 or
|
||||||
|
// 2048x8192 random pixels with an alpha channel takes up to about 34
|
||||||
|
// seconds, the slowest at any quality and at 8 or 16 bits per sample, and
|
||||||
|
// 5120x4096 at 8 bits already takes about 36.
|
||||||
|
const MaxAVIFOutputPixels = 4096 * 4096
|
||||||
|
|
||||||
|
// ErrAVIFOutputTooLarge is returned, before anything is decoded, when an AVIF
|
||||||
|
// output would have more than MaxAVIFOutputPixels pixels.
|
||||||
|
var ErrAVIFOutputTooLarge = errors.New("AVIF output has too many pixels")
|
||||||
|
|
||||||
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
// 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")
|
||||||
@@ -144,7 +149,7 @@ type Params struct {
|
|||||||
// If <= 0, DefaultMaxInputBytes is used.
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
MaxInputBytes int64
|
MaxInputBytes int64
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
// If <= 0, config.DefaultMaxConcurrentProcessing() is used.
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +165,7 @@ func New(params Params) *ImageProcessor {
|
|||||||
|
|
||||||
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
||||||
if maxConcurrentProcessing <= 0 {
|
if maxConcurrentProcessing <= 0 {
|
||||||
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
maxConcurrentProcessing = config.DefaultMaxConcurrentProcessing()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
@@ -170,14 +175,18 @@ func New(params Params) *ImageProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request. When
|
// Process transforms an image according to transform. When
|
||||||
// MaxConcurrentProcessing images are already being processed, it waits up
|
// MaxConcurrentProcessing images are already being processed, it waits up
|
||||||
// to ProcessingWaitTimeout for one to finish, then fails with
|
// to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
// ErrTooManyImages.
|
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
|
||||||
|
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
|
||||||
|
// no way to stop libvips once an encode has begun, so an encode keeps its
|
||||||
|
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
|
||||||
|
// refused with ErrAVIFOutputTooLarge before anything is decoded.
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *Request,
|
transform *imagerequest.Transform,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
release, err := p.acquireSlot(ctx)
|
release, err := p.acquireSlot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,18 +195,20 @@ func (p *ImageProcessor) Process(
|
|||||||
|
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Read input with a size limit to prevent unbounded memory consumption.
|
data, err := p.readInput(input)
|
||||||
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
|
||||||
// the limit without consuming additional memory.
|
|
||||||
limited := io.LimitReader(input, p.maxInputBytes+1)
|
|
||||||
|
|
||||||
data, err := io.ReadAll(limited)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read input: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if int64(len(data)) > p.maxInputBytes {
|
// govips picks the libvips loader from the input's first bytes with
|
||||||
return nil, ErrInputDataTooLarge
|
// 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
|
||||||
@@ -218,33 +229,42 @@ 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(transform.Size,
|
||||||
|
origWidth, origHeight)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// orig is the source's own format; encode refuses an empty format
|
||||||
|
outputFormat := transform.Format
|
||||||
|
if outputFormat == imagerequest.FormatOriginal {
|
||||||
|
outputFormat = sourceFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output dimensions; too large ones are refused before
|
||||||
|
// resizing, which with FitCover already decodes the input
|
||||||
|
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
||||||
|
targetWidth, targetHeight, transform.FitMode, outputFormat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Resize if needed
|
// Resize if needed
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
|
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine output format
|
|
||||||
outputFormat := req.Format
|
|
||||||
if outputFormat == FormatOriginal || outputFormat == "" {
|
|
||||||
outputFormat = p.formatFromString(inputFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode to target format
|
// Encode to target format
|
||||||
output, err := p.encode(img, outputFormat, req.Quality)
|
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
}
|
}
|
||||||
@@ -263,21 +283,33 @@ 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 is
|
||||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
// rounded down but kept at 1 or more, as for a thin image it would round
|
||||||
|
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
|
||||||
|
// which is ErrOutputTooLarge.
|
||||||
|
func targetDimensions(
|
||||||
|
size imagerequest.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 = max(1, 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 = max(1, 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 +319,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,34 +330,38 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
|
|||||||
mimeGIF,
|
mimeGIF,
|
||||||
mimeWebP,
|
mimeWebP,
|
||||||
mimeAVIF,
|
mimeAVIF,
|
||||||
|
mimeJXL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedOutputFormats returns formats this processor can write.
|
// SupportedOutputFormats returns formats this processor can write.
|
||||||
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
func (p *ImageProcessor) SupportedOutputFormats() []imagerequest.Format {
|
||||||
return []Format{
|
return []imagerequest.Format{
|
||||||
FormatJPEG,
|
imagerequest.FormatJPEG,
|
||||||
FormatPNG,
|
imagerequest.FormatPNG,
|
||||||
FormatGIF,
|
imagerequest.FormatGIF,
|
||||||
FormatWebP,
|
imagerequest.FormatWebP,
|
||||||
FormatAVIF,
|
imagerequest.FormatAVIF,
|
||||||
|
imagerequest.FormatJXL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatToMIME converts a Format to its MIME type string.
|
// FormatToMIME converts a Format to its MIME type string.
|
||||||
func FormatToMIME(format Format) string {
|
func FormatToMIME(format imagerequest.Format) string {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case imagerequest.FormatJPEG:
|
||||||
return mimeJPEG
|
return mimeJPEG
|
||||||
case FormatPNG:
|
case imagerequest.FormatPNG:
|
||||||
return mimePNG
|
return mimePNG
|
||||||
case FormatWebP:
|
case imagerequest.FormatWebP:
|
||||||
return mimeWebP
|
return mimeWebP
|
||||||
case FormatGIF:
|
case imagerequest.FormatGIF:
|
||||||
return mimeGIF
|
return mimeGIF
|
||||||
case FormatAVIF:
|
case imagerequest.FormatAVIF:
|
||||||
return mimeAVIF
|
return mimeAVIF
|
||||||
case FormatOriginal:
|
case imagerequest.FormatJXL:
|
||||||
|
return mimeJXL
|
||||||
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
@@ -358,31 +395,50 @@ func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
|||||||
|
|
||||||
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
||||||
// processingWaitTimeout for one to free up, and returns the func that gives
|
// processingWaitTimeout for one to free up, and returns the func that gives
|
||||||
// it back. A free slot is taken even when ctx has ended; only the wait for
|
// it back. When ctx has ended by the time it has a slot, it gives the slot
|
||||||
// one stops when ctx ends, as the rest of Process does not check ctx.
|
// back and returns ctx's error.
|
||||||
func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
||||||
release := func() { <-p.processingSemaphore }
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case p.processingSemaphore <- struct{}{}:
|
case p.processingSemaphore <- struct{}{}:
|
||||||
return release, nil
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case p.processingSemaphore <- struct{}{}:
|
|
||||||
return release, nil
|
|
||||||
case <-time.After(p.processingWaitTimeout):
|
case <-time.After(p.processingWaitTimeout):
|
||||||
return nil, ErrTooManyImages
|
return nil, ErrTooManyImages
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
release := func() { <-p.processingSemaphore }
|
||||||
|
|
||||||
|
// select takes the slot even when ctx has ended at the same moment
|
||||||
|
err := ctx.Err()
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// readInput reads input with a size limit to prevent unbounded memory
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
// consumption, refusing more than maxInputBytes with ErrInputDataTooLarge. It
|
||||||
format := img.Format()
|
// reads at most maxInputBytes+1 so it can detect if the input exceeds the
|
||||||
switch format {
|
// limit without consuming additional memory.
|
||||||
|
func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(input, p.maxInputBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > p.maxInputBytes {
|
||||||
|
return nil, ErrInputDataTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFormat returns the format string for a govips image type.
|
||||||
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||||
|
switch imageType {
|
||||||
case vips.ImageTypeJPEG:
|
case vips.ImageTypeJPEG:
|
||||||
return "jpeg"
|
return "jpeg"
|
||||||
case vips.ImageTypePNG:
|
case vips.ImageTypePNG:
|
||||||
@@ -392,117 +448,157 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
|||||||
case vips.ImageTypeWEBP:
|
case vips.ImageTypeWEBP:
|
||||||
return "webp"
|
return "webp"
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
return string(FormatAVIF)
|
return string(imagerequest.FormatAVIF)
|
||||||
|
case vips.ImageTypeJXL:
|
||||||
|
return string(imagerequest.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.
|
// outputDimensions returns the dimensions resize gives an image of imgWidth x
|
||||||
|
// imgHeight for the target dimensions and fit mode, without resizing it, and
|
||||||
|
// refuses an output too large for format. With FitOutside one side can come
|
||||||
|
// out larger than the target; when it would be above MaxOutputDimension,
|
||||||
|
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
|
||||||
|
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
|
||||||
|
func outputDimensions(
|
||||||
|
imgWidth, imgHeight, width, height int,
|
||||||
|
fit imagerequest.Fit, format imagerequest.Format,
|
||||||
|
) (int, int, error) {
|
||||||
|
scaleW := float64(width) / float64(imgWidth)
|
||||||
|
scaleH := float64(height) / float64(imgHeight)
|
||||||
|
outputWidth, outputHeight := width, height
|
||||||
|
|
||||||
|
switch fit {
|
||||||
|
case imagerequest.FitCover, imagerequest.FitFill, "":
|
||||||
|
// Cover crops, and fill stretches, to exactly the target dimensions
|
||||||
|
|
||||||
|
case imagerequest.FitContain:
|
||||||
|
// Fit within the target dimensions, maintaining aspect ratio. Scaled
|
||||||
|
// by the smaller scale, the short side of a thin image can round
|
||||||
|
// down to 0, which libvips refuses as a width, so each side is at
|
||||||
|
// least 1.
|
||||||
|
scale := min(scaleW, scaleH)
|
||||||
|
outputWidth = max(1, int(float64(imgWidth)*scale))
|
||||||
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
|
case imagerequest.FitInside:
|
||||||
|
// Same as contain, but only shrink
|
||||||
|
scale := min(scaleW, scaleH, 1)
|
||||||
|
outputWidth = max(1, int(float64(imgWidth)*scale))
|
||||||
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
|
case imagerequest.FitOutside:
|
||||||
|
// The smallest dimension fits, the other may exceed the target. A
|
||||||
|
// side scaled to 1 can come out just under 1 in floating point, as
|
||||||
|
// 49 times 1/49 does, and round down to 0, which libvips refuses as
|
||||||
|
// a width, so each side is at least 1.
|
||||||
|
scale := max(scaleW, scaleH)
|
||||||
|
outputWidth = max(1, int(float64(imgWidth)*scale))
|
||||||
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0, 0, fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The side FitOutside scales past the target is held to the maximum too
|
||||||
|
if max(outputWidth, outputHeight) > MaxOutputDimension {
|
||||||
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
|
||||||
|
outputWidth, outputHeight, MaxOutputDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
if format == imagerequest.FormatAVIF &&
|
||||||
|
outputWidth*outputHeight > MaxAVIFOutputPixels {
|
||||||
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
|
||||||
|
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputWidth, outputHeight, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize resizes the image to width x height, the dimensions
|
||||||
|
// outputDimensions gives for the fit mode.
|
||||||
func (p *ImageProcessor) resize(
|
func (p *ImageProcessor) resize(
|
||||||
img *vips.ImageRef, width, height int, fit FitMode,
|
img *vips.ImageRef, width, height int, fit imagerequest.Fit,
|
||||||
) error {
|
) error {
|
||||||
switch fit {
|
switch fit {
|
||||||
case FitCover, "":
|
case imagerequest.FitCover, "":
|
||||||
// Resize and crop to fill exact dimensions (default)
|
// Crop the centre and resize to fill exact dimensions (default)
|
||||||
return img.Thumbnail(width, height, vips.InterestingCentre)
|
return cropCentreAndResize(img, width, height)
|
||||||
|
|
||||||
case FitContain:
|
case imagerequest.FitFill:
|
||||||
// Resize to fit within dimensions, maintaining aspect ratio
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
|
||||||
scaleW := float64(width) / float64(imgW)
|
|
||||||
scaleH := float64(height) / float64(imgH)
|
|
||||||
scale := min(scaleW, scaleH)
|
|
||||||
newW := int(float64(imgW) * scale)
|
|
||||||
newH := int(float64(imgH) * scale)
|
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
|
||||||
|
|
||||||
case FitFill:
|
|
||||||
// Resize to exact dimensions (may distort)
|
// Resize to exact dimensions (may distort)
|
||||||
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||||
|
|
||||||
case FitInside:
|
case imagerequest.FitInside:
|
||||||
// Same as contain, but only shrink
|
// outputDimensions keeps the dimensions of an image that already fits
|
||||||
if img.Width() <= width && img.Height() <= height {
|
if img.Width() == width && img.Height() == height {
|
||||||
return nil // Already fits
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
return img.Thumbnail(width, height, vips.InterestingNone)
|
||||||
scaleW := float64(width) / float64(imgW)
|
|
||||||
scaleH := float64(height) / float64(imgH)
|
|
||||||
scale := min(scaleW, scaleH)
|
|
||||||
newW := int(float64(imgW) * scale)
|
|
||||||
newH := int(float64(imgH) * scale)
|
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
case imagerequest.FitContain, imagerequest.FitOutside:
|
||||||
|
return img.Thumbnail(width, height, vips.InterestingNone)
|
||||||
case FitOutside:
|
|
||||||
// Resize so smallest dimension fits, may exceed target on other dimension
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
|
||||||
scaleW := float64(width) / float64(imgW)
|
|
||||||
scaleH := float64(height) / float64(imgH)
|
|
||||||
scale := max(scaleW, scaleH)
|
|
||||||
newW := int(float64(imgW) * scale)
|
|
||||||
newH := int(float64(imgH) * scale)
|
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
return fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cropCentreAndResize scales img by the same amount on both sides, the
|
||||||
|
// least that makes it cover width x height, and cuts the centre width x
|
||||||
|
// height out of it, as libvips' thumbnail does with InterestingCentre.
|
||||||
|
// thumbnail alone scales the whole image and copies the result into memory
|
||||||
|
// before it cuts, and for a thin image that is far larger than the image or
|
||||||
|
// the output: 8192x64 at 8192x8192 is 1048576x8192, about 24 GiB. So the
|
||||||
|
// centre is cut from img first, widened to whole pixels of img, which adds
|
||||||
|
// less than one pixel of img on each side, and thumbnail scales and cuts
|
||||||
|
// only that.
|
||||||
|
func cropCentreAndResize(img *vips.ImageRef, width, height int) error {
|
||||||
|
imgW, imgH := img.Width(), img.Height()
|
||||||
|
|
||||||
|
// An image wider than the requested proportions keeps its height and
|
||||||
|
// loses width down to imgH*width/height, any other keeps its width and
|
||||||
|
// loses height down to imgW*height/width. Half of what is lost comes
|
||||||
|
// off each side, rounded down to whole pixels, so the part kept holds
|
||||||
|
// all of the centre.
|
||||||
|
left, top := 0, 0
|
||||||
|
if imgW*height > imgH*width {
|
||||||
|
left = (imgW*height - imgH*width) / (2 * height) //nolint:mnd // each side
|
||||||
|
} else {
|
||||||
|
top = (imgH*width - imgW*height) / (2 * width) //nolint:mnd // each side
|
||||||
|
}
|
||||||
|
|
||||||
|
err := img.ExtractArea(left, top, imgW-2*left, imgH-2*top)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return img.Thumbnail(width, height, vips.InterestingCentre)
|
||||||
|
}
|
||||||
|
|
||||||
const defaultQuality = 85
|
const defaultQuality = 85
|
||||||
|
|
||||||
// encode encodes an image to the specified format.
|
// encode encodes an image to the specified format. govips gives pixa no way
|
||||||
|
// to stop libvips once an encode has begun, so when ctx has ended encode
|
||||||
|
// returns ctx's error without beginning one.
|
||||||
func (p *ImageProcessor) encode(
|
func (p *ImageProcessor) encode(
|
||||||
img *vips.ImageRef, format Format, quality int,
|
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
|
||||||
|
quality int,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
if quality <= 0 {
|
err := ctx.Err()
|
||||||
quality = defaultQuality
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var params vips.ExportParams
|
if quality <= 0 {
|
||||||
|
quality = defaultQuality
|
||||||
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
|
||||||
@@ -510,17 +606,188 @@ func (p *ImageProcessor) encode(
|
|||||||
// libvips' built-in profile; govips' own sRGB path variable is set on
|
// libvips' built-in profile; govips' own sRGB path variable is set on
|
||||||
// first use but read without a lock, so concurrent requests race on it.
|
// first use but read without a lock, so concurrent requests race on it.
|
||||||
if img.HasICCProfile() {
|
if img.HasICCProfile() {
|
||||||
err := img.TransformICCProfileWithFallback("srgb", "srgb")
|
err = img.TransformICCProfileWithFallback("srgb", "srgb")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
|
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
switch format {
|
||||||
// GIF, which carries none of them.
|
case imagerequest.FormatJPEG:
|
||||||
params.StripMetadata = true
|
return exportJPEG(img, quality)
|
||||||
|
|
||||||
output, _, err := img.Export(¶ms)
|
case imagerequest.FormatPNG:
|
||||||
|
return exportPNG(img)
|
||||||
|
|
||||||
|
case imagerequest.FormatGIF:
|
||||||
|
return exportGIF(img)
|
||||||
|
|
||||||
|
case imagerequest.FormatWebP:
|
||||||
|
return exportWebP(img, quality)
|
||||||
|
|
||||||
|
case imagerequest.FormatAVIF:
|
||||||
|
return exportAVIF(img, quality)
|
||||||
|
|
||||||
|
case imagerequest.FormatJXL:
|
||||||
|
return exportJXL(img, quality)
|
||||||
|
|
||||||
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
|
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 the WebP effort, from 0 (fastest) to 6. It is below libvips'
|
||||||
|
// default of 4 so that an encode takes at most 40 seconds of the default
|
||||||
|
// downstream_timeout of 60 seconds. With one thread, as pixad runs libvips,
|
||||||
|
// saving an 8192x8192 image of random pixels with an alpha channel takes up
|
||||||
|
// to about 23 seconds at 2, and up to about 16 without one, at any quality;
|
||||||
|
// with an alpha channel, 3 takes about 50 seconds and 4 about 61.
|
||||||
|
const webpEffort = 2
|
||||||
|
|
||||||
|
// exportWebP encodes img as lossy WebP at quality and webpEffort, without
|
||||||
|
// metadata.
|
||||||
|
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportWebp(&vips.WebpExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: quality,
|
||||||
|
ReductionEffort: webpEffort,
|
||||||
|
})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// avifEffort is the AVIF effort, from 0 (fastest) to 9; 1 is the lowest
|
||||||
|
// govips can set. With one thread, as pixad runs libvips, 1 takes about 52
|
||||||
|
// seconds to save an 8192x8192 RGB image of random pixels at quality 85,
|
||||||
|
// and about 96 with an alpha channel, against the default
|
||||||
|
// downstream_timeout of 60 seconds, so an AVIF output is held to
|
||||||
|
// MaxAVIFOutputPixels. On an image of milder noise, which 1 saves in about
|
||||||
|
// 12 seconds, 2 takes nearly a minute and libvips' default, 4, takes
|
||||||
|
// minutes.
|
||||||
|
const avifEffort = 1
|
||||||
|
|
||||||
|
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
|
||||||
|
// libvips would save a 16-bit image with 12, but at avifEffort that takes
|
||||||
|
// about 54 seconds for a 16-bit 8192x8192 image of milder noise, nearly all
|
||||||
|
// of the default downstream_timeout, and about 12 seconds with 8.
|
||||||
|
const avifBitdepth = 8
|
||||||
|
|
||||||
|
// exportAVIF encodes img as lossy AVIF at quality, avifEffort and
|
||||||
|
// avifBitdepth, without metadata.
|
||||||
|
func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportAvif(&vips.AvifExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: quality,
|
||||||
|
Effort: avifEffort,
|
||||||
|
Bitdepth: avifBitdepth,
|
||||||
|
})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// jxlResolution is the resolution every JPEG XL image is saved with, in
|
||||||
|
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
|
||||||
|
// JPEG that names none.
|
||||||
|
const jxlResolution = 72 / 25.4
|
||||||
|
|
||||||
|
// jxlEffort is the JPEG XL effort, from 1 (fastest) to 9. It is below
|
||||||
|
// libvips' default of 7 so that an encode takes at most 40 seconds of the
|
||||||
|
// default downstream_timeout of 60 seconds. With one thread, as pixad runs
|
||||||
|
// libvips, saving an 8192x8192 image of random pixels with an alpha channel
|
||||||
|
// takes up to about 27 seconds at 4, and up to about 13 without one, at any
|
||||||
|
// quality; with an alpha channel, 5 takes about 43 seconds and 7 about 81.
|
||||||
|
const jxlEffort = 4
|
||||||
|
|
||||||
|
// exportJXL encodes img as JPEG XL at quality and jxlEffort, without
|
||||||
|
// metadata. govips sends libvips a distance, the JPEG XL encoder's own
|
||||||
|
// measure of quality, along with the quality, and libvips then uses the
|
||||||
|
// distance alone, so the quality is also given as a distance.
|
||||||
|
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
|
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
|
||||||
|
// PNG, but cannot save one as JPEG XL. encode has already converted
|
||||||
|
// any image with an ICC profile to sRGB, so this is CMYK with none.
|
||||||
|
if img.Interpretation() == vips.InterpretationCMYK {
|
||||||
|
err := img.ToColorSpace(vips.InterpretationSRGB)
|
||||||
|
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)
|
||||||
|
params.Effort = jxlEffort
|
||||||
|
|
||||||
|
output, _, err := toSave.ExportJxl(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -528,20 +795,41 @@ 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,
|
||||||
|
) (imagerequest.Format, error) {
|
||||||
switch format {
|
switch format {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return FormatJPEG
|
return imagerequest.FormatJPEG, nil
|
||||||
case "png":
|
case "png":
|
||||||
return FormatPNG
|
return imagerequest.FormatPNG, nil
|
||||||
case "gif":
|
case "gif":
|
||||||
return FormatGIF
|
return imagerequest.FormatGIF, nil
|
||||||
case "webp":
|
case "webp":
|
||||||
return FormatWebP
|
return imagerequest.FormatWebP, nil
|
||||||
case string(FormatAVIF):
|
case string(imagerequest.FormatAVIF):
|
||||||
return FormatAVIF
|
return imagerequest.FormatAVIF, nil
|
||||||
|
case string(imagerequest.FormatJXL):
|
||||||
|
return imagerequest.FormatJXL, nil
|
||||||
default:
|
default:
|
||||||
return FormatJPEG
|
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -87,7 +88,7 @@ func isAVIF(data []byte) bool {
|
|||||||
|
|
||||||
brand := string(data[8:12])
|
brand := string(data[8:12])
|
||||||
|
|
||||||
return brand == string(FormatAVIF) || brand == "avis"
|
return brand == string(imagerequest.FormatAVIF) || brand == "avis"
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectMIME is a minimal magic-byte detector for test assertions.
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
@@ -123,14 +124,14 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 400, Height: 300},
|
Size: imagerequest.Size{Width: 400, Height: 300},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,13 +170,13 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: imagerequest.Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,7 +197,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
// processAndCheckSize processes a test JPEG of the given input dimensions
|
// processAndCheckSize processes a test JPEG of the given input dimensions
|
||||||
// with the requested size and asserts the resulting dimensions.
|
// with the requested size and asserts the resulting dimensions.
|
||||||
func processAndCheckSize(
|
func processAndCheckSize(
|
||||||
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
|
t *testing.T, inputW, inputH int, size imagerequest.Size, wantW, wantH int,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -205,14 +206,14 @@ func processAndCheckSize(
|
|||||||
|
|
||||||
input := createTestJPEG(t, inputW, inputH)
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: size,
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -232,7 +233,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Width and height 0: keep original size
|
// Width and height 0: keep original size
|
||||||
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
|
processAndCheckSize(t, 640, 480, imagerequest.Size{Width: 0, Height: 0}, 640, 480)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
@@ -245,14 +246,14 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
// Should result in 400x200 (maintaining aspect ratio)
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
input := createTestJPEG(t, 800, 400)
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 400, Height: 400},
|
Size: imagerequest.Size{Width: 400, Height: 400},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,7 +271,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
|||||||
|
|
||||||
// 800x600 image, request width=400 height=0
|
// 800x600 image, request width=400 height=0
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
|
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 400, Height: 0}, 400, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
@@ -278,7 +279,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
|||||||
|
|
||||||
// 800x600 image, request width=0 height=300
|
// 800x600 image, request width=0 height=300
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
|
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 0, Height: 300}, 400, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
@@ -289,13 +290,13 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestPNG(t, 400, 300)
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: imagerequest.Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -349,14 +350,14 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
input := createTestJPEG(t, tt.width, tt.height)
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Process() should reject oversized input images")
|
t.Error("Process() should reject oversized input images")
|
||||||
}
|
}
|
||||||
@@ -377,14 +378,14 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
// Create an image at exactly MaxInputDimension - should be accepted
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
input := createTestJPEG(t, MaxInputDimension, 100)
|
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"Process() should accept images at MaxInputDimension, got error: %v",
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
@@ -397,7 +398,9 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
|
|
||||||
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
||||||
// given format and asserts the output MIME type and dimensions.
|
// given format and asserts the output MIME type and dimensions.
|
||||||
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
func encodeAndCheck(
|
||||||
|
t *testing.T, format imagerequest.Format, quality int, wantMIME string,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{})
|
||||||
@@ -405,14 +408,14 @@ func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -443,7 +446,7 @@ func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
|||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
|
encodeAndCheck(t, imagerequest.FormatWebP, 80, mimeWebP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
@@ -459,14 +462,14 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request resize and convert to JPEG
|
// Request resize and convert to JPEG
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 2, Height: 2},
|
Size: imagerequest.Size{Width: 2, Height: 2},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
}
|
}
|
||||||
@@ -500,14 +503,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
}
|
}
|
||||||
@@ -527,14 +530,14 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
proc := New(Params{MaxInputBytes: limit})
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 10, Height: 10},
|
Size: imagerequest.Size{Width: 10, Height: 10},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -561,16 +564,18 @@ func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
|||||||
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
encodeAndCheck(t, imagerequest.FormatAVIF, 85, mimeAVIF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndDecode runs input through Process and decodes the output with
|
// processAndDecode runs input through Process and decodes the output with
|
||||||
// vips, so a test can inspect the image a client would receive.
|
// vips, so a test can inspect the image a client would receive.
|
||||||
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
|
func processAndDecode(
|
||||||
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
|
) *vips.ImageRef {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{}).Process(
|
||||||
context.Background(), bytes.NewReader(input), req,
|
context.Background(), bytes.NewReader(input), transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -614,15 +619,17 @@ func TestImageProcessor_StripsEXIF(t *testing.T) {
|
|||||||
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
||||||
}
|
}
|
||||||
|
|
||||||
formats := []Format{
|
formats := []imagerequest.Format{
|
||||||
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatWebP,
|
||||||
|
imagerequest.FormatAVIF, imagerequest.FormatGIF, imagerequest.FormatOriginal,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, format := range formats {
|
for _, format := range formats {
|
||||||
t.Run(string(format), func(t *testing.T) {
|
t.Run(string(format), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: format})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: format})
|
||||||
|
|
||||||
if output.HasExif() {
|
if output.HasExif() {
|
||||||
t.Errorf("output has EXIF: %v", output.GetExif())
|
t.Errorf("output has EXIF: %v", output.GetExif())
|
||||||
@@ -644,21 +651,21 @@ func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantW int
|
wantW int
|
||||||
wantH int
|
wantH int
|
||||||
}{
|
}{
|
||||||
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
|
{name: "original size", size: imagerequest.Size{}, wantW: 8, wantH: 16},
|
||||||
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
|
{name: "width only", size: imagerequest.Size{Width: 4}, wantW: 4, wantH: 8},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{
|
output := processAndDecode(t, input, &imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
})
|
})
|
||||||
|
|
||||||
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
||||||
@@ -694,7 +701,8 @@ func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatPNG})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
if output.HasICCProfile() {
|
if output.HasICCProfile() {
|
||||||
t.Error("output has an ICC profile")
|
t.Error("output has an ICC profile")
|
||||||
|
|||||||
@@ -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,254 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
transform := &imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
|
Format: imagerequest.FormatJXL,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/iotest"
|
"testing/iotest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errTestReadFailed is the error the unreadable test input returns.
|
// errTestReadFailed is the error the unreadable test input returns.
|
||||||
@@ -76,13 +78,13 @@ func (r *gatedReader) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// smallJPEGRequest asks for a 5x5 JPEG.
|
// smallJPEGTransform asks for a 5x5 JPEG.
|
||||||
func smallJPEGRequest() *Request {
|
func smallJPEGTransform() *imagerequest.Transform {
|
||||||
return &Request{
|
return &imagerequest.Transform{
|
||||||
Size: Size{Width: 5, Height: 5},
|
Size: imagerequest.Size{Width: 5, Height: 5},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ func processInBackground(
|
|||||||
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
) {
|
) {
|
||||||
go func() {
|
go func() {
|
||||||
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
result, err := proc.Process(context.Background(), reader, smallJPEGTransform())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = result.Content.Close()
|
_ = result.Content.Close()
|
||||||
}
|
}
|
||||||
@@ -115,14 +117,14 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, limit := range []int{0, -1} {
|
for _, limit := range []int{0, -1} {
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
if got := cap(proc.processingSemaphore); got != max(runtime.GOMAXPROCS(0)/2, 1) {
|
||||||
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, half the CPUs",
|
||||||
limit, got, runtime.GOMAXPROCS(0))
|
limit, got, max(runtime.GOMAXPROCS(0)/2, 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +134,30 @@ func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewDefaultIsHalfTheCPUsAndAtLeastOne checks that New, given no
|
||||||
|
// MaxConcurrentProcessing, makes one slot for every two CPUs Go uses,
|
||||||
|
// rounded down, and one slot when Go uses one CPU.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
|
||||||
|
func TestNewDefaultIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
|
||||||
|
cpusBefore := runtime.GOMAXPROCS(0)
|
||||||
|
|
||||||
|
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
|
||||||
|
|
||||||
|
for _, tc := range []struct{ cpus, want int }{
|
||||||
|
{1, 1}, {2, 1}, {3, 1}, {8, 4},
|
||||||
|
} {
|
||||||
|
runtime.GOMAXPROCS(tc.cpus)
|
||||||
|
|
||||||
|
got := cap(New(Params{}).processingSemaphore)
|
||||||
|
t.Logf("GOMAXPROCS %d: %d slots", tc.cpus, got)
|
||||||
|
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("GOMAXPROCS %d: %d slots, want %d", tc.cpus, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
||||||
// than MaxConcurrentProcessing allows and holds the first ones inside
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
||||||
// Process until the test lets them go. No more than the limit may be
|
// Process until the test lets them go. No more than the limit may be
|
||||||
@@ -213,7 +239,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
smallJPEGRequest())
|
smallJPEGTransform())
|
||||||
if !errors.Is(err, ErrTooManyImages) {
|
if !errors.Is(err, ErrTooManyImages) {
|
||||||
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
||||||
}
|
}
|
||||||
@@ -239,38 +265,38 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
|
|
||||||
valid := createTestJPEG(t, 10, 10)
|
valid := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
unsupported := smallJPEGRequest()
|
unsupported := smallJPEGTransform()
|
||||||
unsupported.Format = "bmp"
|
unsupported.Format = "bmp"
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
input io.Reader
|
input io.Reader
|
||||||
req *Request
|
transform *imagerequest.Transform
|
||||||
// want is the error Process must return; nil means any error.
|
// want is the error Process must return; nil means any error.
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "input cannot be read",
|
name: "input cannot be read",
|
||||||
input: iotest.ErrReader(errTestReadFailed),
|
input: iotest.ErrReader(errTestReadFailed),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
want: errTestReadFailed,
|
want: errTestReadFailed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "input over the byte limit",
|
name: "input over the byte limit",
|
||||||
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
want: ErrInputDataTooLarge,
|
want: ErrInputDataTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "input not an image",
|
name: "input not an image",
|
||||||
input: strings.NewReader("not an image"),
|
input: strings.NewReader("not an image"),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "output format not supported",
|
name: "output format not supported",
|
||||||
input: bytes.NewReader(valid),
|
input: bytes.NewReader(valid),
|
||||||
req: unsupported,
|
transform: unsupported,
|
||||||
want: ErrUnsupportedOutputFormat,
|
want: ErrUnsupportedOutputFormat,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +307,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
_, err := proc.Process(context.Background(), tc.input, tc.transform)
|
||||||
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||||
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -291,7 +317,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
smallJPEGRequest())
|
smallJPEGTransform())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() after the error = %v, want nil", err)
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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,50 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 []imagerequest.Format{
|
||||||
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
|
} {
|
||||||
|
saved, err := proc.Process(t.Context(),
|
||||||
|
bytes.NewReader(createTestPNG(t, 20, 20)),
|
||||||
|
&imagerequest.Transform{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),
|
||||||
|
&imagerequest.Transform{Format: imagerequest.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 |
@@ -0,0 +1,147 @@
|
|||||||
|
// Package imagerequest holds the image request type, its parts, its checks,
|
||||||
|
// the parsing of the /v1/image/ path, the variant key and the source key. It
|
||||||
|
// imports no other package of this repo, so every package that handles an
|
||||||
|
// image request can import it.
|
||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
|
// Format represents supported output image formats.
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
// Supported image output formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal Format = "orig"
|
||||||
|
FormatJPEG Format = "jpeg"
|
||||||
|
FormatPNG Format = "png"
|
||||||
|
FormatWebP Format = "webp"
|
||||||
|
FormatAVIF Format = "avif"
|
||||||
|
FormatJXL Format = "jxl"
|
||||||
|
FormatGIF Format = "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 Format = "auto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Size represents requested image dimensions
|
||||||
|
type Size struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// OriginalSize returns true if this represents "keep original size"
|
||||||
|
func (s Size) OriginalSize() bool {
|
||||||
|
return s.Width == 0 && s.Height == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit represents how to fit image into requested dimensions.
|
||||||
|
type Fit string
|
||||||
|
|
||||||
|
// Supported image fit modes.
|
||||||
|
const (
|
||||||
|
FitCover Fit = "cover"
|
||||||
|
FitContain Fit = "contain"
|
||||||
|
FitFill Fit = "fill"
|
||||||
|
FitInside Fit = "inside"
|
||||||
|
FitOutside Fit = "outside"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateFitMode checks if the given fit mode is valid.
|
||||||
|
// Returns ErrInvalidFitMode for unrecognized fit modes.
|
||||||
|
func ValidateFitMode(fit Fit) error {
|
||||||
|
switch fit {
|
||||||
|
case FitCover, FitContain, FitFill, FitInside, FitOutside, "":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrInvalidFitMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateImageRequest checks a request's width and height with
|
||||||
|
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
|
||||||
|
// /v1/image/ route and the encrypted /v1/e/ route validate through this
|
||||||
|
// function so a request from either source enforces identical bounds.
|
||||||
|
func ValidateImageRequest(req *Request) error {
|
||||||
|
err := ValidateDimension("width", req.Transform.Size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", req.Transform.Size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateFitMode(req.Transform.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request represents a request for a processed image: the source to fetch
|
||||||
|
// and the transform to apply to it.
|
||||||
|
type Request struct {
|
||||||
|
// Source is what to fetch
|
||||||
|
Source Source
|
||||||
|
// Transform is what to do to it
|
||||||
|
Transform Transform
|
||||||
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
|
Signature string
|
||||||
|
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
||||||
|
// of an encrypted URL; the zero time if it has none
|
||||||
|
Expires time.Time
|
||||||
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
|
AllowHTTP bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source is the image a request fetches from the origin.
|
||||||
|
type Source struct {
|
||||||
|
// Host is the origin host (e.g., "cdn.example.com")
|
||||||
|
Host string
|
||||||
|
// Path is the path on the origin with its percent-escapes decoded, as
|
||||||
|
// url.URL's Path holds it (e.g., "/photos/my cat.jpg")
|
||||||
|
Path string
|
||||||
|
// Query is the optional query string for the origin URL as the URL
|
||||||
|
// writes it, percent-escapes included, as url.URL's RawQuery holds it
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform is what a request does to its source; the image processor
|
||||||
|
// takes it alone.
|
||||||
|
type Transform struct {
|
||||||
|
// Size is the requested output dimensions
|
||||||
|
Size Size
|
||||||
|
// Format is the requested output format
|
||||||
|
Format Format
|
||||||
|
// Quality is the output quality (1-100) for lossy formats
|
||||||
|
Quality int
|
||||||
|
// FitMode is how to fit the image into requested dimensions
|
||||||
|
FitMode Fit
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceURL returns the full upstream URL to fetch: the source path
|
||||||
|
// percent-encoded again, as net/url writes a path, so each source path is one
|
||||||
|
// upstream path, and the source query as it is, with the characters
|
||||||
|
// escapeQuery encodes percent-encoded. Uses http:// scheme when AllowHTTP is
|
||||||
|
// true, otherwise https://.
|
||||||
|
func (r *Request) SourceURL() string {
|
||||||
|
scheme := "https"
|
||||||
|
if r.AllowHTTP {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURL := url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: r.Source.Host,
|
||||||
|
Path: r.Source.Path,
|
||||||
|
RawQuery: escapeQuery(r.Source.Query),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceURL.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceKey is a SHA256 hash identifying a cached source image (hex-encoded).
|
||||||
|
type SourceKey string
|
||||||
|
|
||||||
|
// Key returns the key the source is cached under, made as CacheKey makes a
|
||||||
|
// variant key.
|
||||||
|
// Format: sha256(host:path:query)
|
||||||
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
||||||
|
// either cannot be taken for the one between them.
|
||||||
|
func (s Source) Key() SourceKey {
|
||||||
|
data := fmt.Sprintf("%s:%s:%s",
|
||||||
|
s.Host,
|
||||||
|
escapePercentAndColon(s.Path),
|
||||||
|
escapePercentAndColon(s.Query),
|
||||||
|
)
|
||||||
|
h := sha256.Sum256([]byte(data))
|
||||||
|
|
||||||
|
return SourceKey(hex.EncodeToString(h[:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestSourceKey_DiffersForEachSource checks that each source gets a key of
|
||||||
|
// 64 hex characters, the same each time, and that no two of these sources
|
||||||
|
// share one, also when they differ only by a ":" or "%" in the path or query.
|
||||||
|
func TestSourceKey_DiffersForEachSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sources := []Source{
|
||||||
|
{Host: testHostCDN, Path: testPathCat},
|
||||||
|
{Host: testHostCDN, Path: testPathCat, Query: "v=2"},
|
||||||
|
{Host: "other.example.com", Path: testPathCat},
|
||||||
|
{Host: testHostCDN, Path: "/c:d"},
|
||||||
|
{Host: testHostCDN, Path: "/c", Query: "d:"},
|
||||||
|
{Host: testHostCDN, Path: "/c%3Ad"},
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[SourceKey]Source)
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
key := source.Key()
|
||||||
|
t.Logf("Key() of %+v = %s", source, key)
|
||||||
|
|
||||||
|
if len(key) != 64 {
|
||||||
|
t.Errorf("Key() of %+v length = %d, want 64", source, len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if again := source.Key(); again != key {
|
||||||
|
t.Errorf("Key() of %+v not deterministic: %s vs %s", source, key, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
if earlier, found := seen[key]; found {
|
||||||
|
t.Errorf("Key() gave %+v and %+v the same key", earlier, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = source
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-16
@@ -1,14 +1,16 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
func TestRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
SourceHost: testHostCDN,
|
Source: Source{
|
||||||
SourcePath: testPathCat,
|
Host: testHostCDN,
|
||||||
SourceQuery: "v=2",
|
Path: testPathCat,
|
||||||
|
Query: "v=2",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := req.SourceURL()
|
got := req.SourceURL()
|
||||||
@@ -19,13 +21,15 @@ func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
func TestRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
SourceHost: "localhost:8080",
|
Source: Source{
|
||||||
SourcePath: testPathCat,
|
Host: "localhost:8080",
|
||||||
AllowHTTP: true,
|
Path: testPathCat,
|
||||||
|
},
|
||||||
|
AllowHTTP: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
got := req.SourceURL()
|
got := req.SourceURL()
|
||||||
@@ -36,13 +40,15 @@ func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
func TestRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
SourceHost: testHostCDN,
|
Source: Source{
|
||||||
SourcePath: "/img.jpg",
|
Host: testHostCDN,
|
||||||
AllowHTTP: false,
|
Path: "/img.jpg",
|
||||||
|
},
|
||||||
|
AllowHTTP: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
got := req.SourceURL()
|
got := req.SourceURL()
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// URL parsing errors.
|
||||||
|
var (
|
||||||
|
ErrInvalidPath = errors.New("invalid image path")
|
||||||
|
ErrMissingHost = errors.New("missing source host")
|
||||||
|
ErrInvalidHost = errors.New("invalid source host")
|
||||||
|
ErrMissingSize = errors.New("missing size specification")
|
||||||
|
ErrInvalidSize = errors.New("invalid size format")
|
||||||
|
ErrInvalidFormat = errors.New("invalid or unsupported format")
|
||||||
|
ErrDimensionTooLarge = errors.New("dimension exceeds maximum")
|
||||||
|
ErrPathTraversal = errors.New("path traversal detected")
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||||
|
// processor's maximum for an output image (imageprocessor.MaxOutputDimension).
|
||||||
|
const MaxDimension = 8192
|
||||||
|
|
||||||
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
|
// "height") appears in the error. 0 is valid: that side is then worked out from
|
||||||
|
// the source's proportions and the other requested side, rounded down but at
|
||||||
|
// least 1, or is the source's own side when the other side is 0 too.
|
||||||
|
func ValidateDimension(name string, value int) error {
|
||||||
|
if value < 0 {
|
||||||
|
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value > MaxDimension {
|
||||||
|
return fmt.Errorf("%w: %s is above %d",
|
||||||
|
ErrDimensionTooLarge, name, MaxDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
|
||||||
|
// and a size with no format, such as "800x600" or "orig"
|
||||||
|
var sizeFormatRegex = regexp.MustCompile(
|
||||||
|
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
|
||||||
|
|
||||||
|
// percentEscapeRegex matches one percent-escape, such as "%2e"
|
||||||
|
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
|
||||||
|
|
||||||
|
// queryEscapeRegex matches a percent-escape, or one character that a browser
|
||||||
|
// percent-encodes in the query of an http or https URL: one outside the
|
||||||
|
// printable ASCII characters "!" to "~", such as a space, a control character
|
||||||
|
// or a character outside ASCII, or one of `"#'<>`. A "%" that does not begin a
|
||||||
|
// percent-escape matches too.
|
||||||
|
var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
|
||||||
|
|
||||||
|
// hostRegex matches the forms the host of an image URL may take: a host name
|
||||||
|
// or an IPv4 address (letters, digits, "-", "_" and "."), or an IPv6 address
|
||||||
|
// in brackets (hex digits, ":" and "."), either one optionally followed by ":"
|
||||||
|
// and a port, which may be empty. Its one group holds the text in brackets,
|
||||||
|
// which parseImageComponents also checks is an IPv6 address.
|
||||||
|
var hostRegex = regexp.MustCompile(
|
||||||
|
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
||||||
|
|
||||||
|
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
|
||||||
|
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
|
||||||
|
// Examples:
|
||||||
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
|
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||||
|
// - cdn.example.com/photos/cat.jpg/orig.png
|
||||||
|
// - cdn.example.com/photos/cat.jpg/800x600
|
||||||
|
func ParseImagePath(path string) (*Request, error) {
|
||||||
|
// Strip leading slash if present (chi may include it)
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return nil, ErrMissingHost
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseImageComponents(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
||||||
|
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
|
||||||
|
// for JPEG XL. A source query follows the source path after a "?", which the
|
||||||
|
// URL writes as %3F.
|
||||||
|
func ParseImageURL(urlPath string) (*Request, error) {
|
||||||
|
// Remove the /v1/image/ prefix
|
||||||
|
const prefix = "/v1/image/"
|
||||||
|
if !strings.HasPrefix(urlPath, prefix) {
|
||||||
|
return nil, ErrInvalidPath
|
||||||
|
}
|
||||||
|
|
||||||
|
remainder := strings.TrimPrefix(urlPath, prefix)
|
||||||
|
if remainder == "" {
|
||||||
|
return nil, ErrMissingHost
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseImageComponents(remainder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||||
|
// <host>/<path>/<size> for JPEG XL.
|
||||||
|
func parseImageComponents(remainder string) (*Request, error) {
|
||||||
|
// Find the last path segment, which holds "size" or "size.format"
|
||||||
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
|
if lastSlash == -1 {
|
||||||
|
return nil, ErrMissingSize
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeFormat := remainder[lastSlash+1:]
|
||||||
|
hostAndPath := remainder[:lastSlash]
|
||||||
|
|
||||||
|
if hostAndPath == "" {
|
||||||
|
return nil, ErrMissingHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse size and format from the last segment
|
||||||
|
size, format, err := parseSizeFormat(sizeFormat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split host from path
|
||||||
|
// The first segment is the host, everything after is the path
|
||||||
|
firstSlash := strings.Index(hostAndPath, "/")
|
||||||
|
|
||||||
|
var host, path, query string
|
||||||
|
|
||||||
|
if firstSlash == -1 {
|
||||||
|
// No path, just host (unusual but valid)
|
||||||
|
host = hostAndPath
|
||||||
|
path = "/"
|
||||||
|
} else {
|
||||||
|
host = hostAndPath[:firstSlash]
|
||||||
|
path = hostAndPath[firstSlash:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "" {
|
||||||
|
return nil, ErrMissingHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
|
||||||
|
// the host would make Go's URL parser, which the allowlist check and the
|
||||||
|
// fetcher use, read a different host from the one allowlisted and cached
|
||||||
|
hostMatch := hostRegex.FindStringSubmatch(host)
|
||||||
|
if hostMatch == nil {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's URL parser refuses brackets around anything but an IPv6 address
|
||||||
|
if inBrackets := hostMatch[1]; inBrackets != "" {
|
||||||
|
addr, err := netip.ParseAddr(inBrackets)
|
||||||
|
if err != nil || !addr.Is6() {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host names do not depend on letter case, so the allowlist, the upstream
|
||||||
|
// connection limits, the cache and the signature see one spelling of each
|
||||||
|
// host
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
|
// Go's URL parser, which the fetcher uses, reads a "#" as the start of a
|
||||||
|
// fragment and drops the rest, so each text after it would be another
|
||||||
|
// spelling, cached under its own keys, of one upstream request
|
||||||
|
if strings.Contains(path, "#") {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
`%w: the source path or query holds a "#"`, ErrInvalidPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract query string if present in path. It is the source URL's query
|
||||||
|
// as that URL writes it. A character a URL's query cannot hold as it is,
|
||||||
|
// such as a space written %20 in the image URL, is percent-encoded here,
|
||||||
|
// so the query is signed and cached as it is sent upstream
|
||||||
|
if qIndex := strings.Index(path, "?"); qIndex != -1 {
|
||||||
|
query = escapeQuery(path[qIndex+1:])
|
||||||
|
path = path[:qIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source query names no file on the upstream, so only the host and
|
||||||
|
// the source path are checked for path traversal
|
||||||
|
err = checkPathTraversal(host + path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure path starts with /
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Request{
|
||||||
|
Source: Source{Host: host, Path: path, Query: query},
|
||||||
|
Transform: Transform{Size: size, Format: format},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPathTraversal detects path traversal attempts in a URL path.
|
||||||
|
// It checks for various attack vectors including:
|
||||||
|
// - Direct ../ sequences
|
||||||
|
// - URL-encoded variants (%2e%2e, %252e%252e)
|
||||||
|
// - Backslash variants (..\)
|
||||||
|
// - Null byte injection (%00)
|
||||||
|
func checkPathTraversal(path string) error {
|
||||||
|
// First, URL-decode the path to catch encoded attacks
|
||||||
|
// Decode multiple times to catch double-encoding
|
||||||
|
decoded := path
|
||||||
|
for range 3 {
|
||||||
|
newDecoded := decodePercentEscapes(decoded)
|
||||||
|
if newDecoded == decoded {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded = newDecoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize backslashes to forward slashes
|
||||||
|
normalized := strings.ReplaceAll(decoded, "\\", "/")
|
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if strings.Contains(normalized, "\x00") {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for parent directory traversal
|
||||||
|
// Look for "/.." or "../" patterns
|
||||||
|
if strings.Contains(normalized, "/../") ||
|
||||||
|
strings.Contains(normalized, "/..") ||
|
||||||
|
strings.HasPrefix(normalized, "../") ||
|
||||||
|
strings.HasSuffix(normalized, "/..") ||
|
||||||
|
normalized == ".." {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for ".." as a path segment in the original path
|
||||||
|
// This catches cases where the path hasn't been normalized
|
||||||
|
for seg := range strings.SplitSeq(path, "/") {
|
||||||
|
// URL decode the segment
|
||||||
|
decodedSeg, _ := url.PathUnescape(seg)
|
||||||
|
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
||||||
|
|
||||||
|
if decodedSeg == ".." {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodePercentEscapes decodes every percent-escape in s, such as %2e, and
|
||||||
|
// keeps a "%" that does not start one as it is, where url.PathUnescape would
|
||||||
|
// refuse the whole string. A source path holding %25, a literal "%", has such
|
||||||
|
// a "%" after one more decoding.
|
||||||
|
func decodePercentEscapes(s string) string {
|
||||||
|
return percentEscapeRegex.ReplaceAllStringFunc(s, func(escape string) string {
|
||||||
|
decoded, _ := url.PathUnescape(escape)
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeQuery returns a source query as a URL's query holds it: each
|
||||||
|
// character that queryEscapeRegex names, such as a space, percent-encoded,
|
||||||
|
// and the rest, the query's percent-escapes included, kept as they are. The
|
||||||
|
// result is unchanged by a second escapeQuery.
|
||||||
|
func escapeQuery(query string) string {
|
||||||
|
return queryEscapeRegex.ReplaceAllStringFunc(query, func(match string) string {
|
||||||
|
if percentEscapeRegex.MatchString(match) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.PathEscape(match)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||||
|
func parseSizeFormat(s string) (Size, Format, error) {
|
||||||
|
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||||
|
if matches == nil {
|
||||||
|
return Size{}, "", ErrInvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
size Size
|
||||||
|
formatStr string
|
||||||
|
)
|
||||||
|
|
||||||
|
if matches[4] == "orig" {
|
||||||
|
// "orig" or "orig.format" pattern
|
||||||
|
size = Size{Width: 0, Height: 0}
|
||||||
|
formatStr = matches[5]
|
||||||
|
} else {
|
||||||
|
// "WxH" or "WxH.format" pattern
|
||||||
|
width, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", ErrInvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
height, err := strconv.Atoi(matches[2])
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", ErrInvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
size = Size{Width: width, Height: height}
|
||||||
|
formatStr = matches[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDimension("width", size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL that names no format is served, and signed, as JPEG XL
|
||||||
|
if formatStr == "" {
|
||||||
|
return size, FormatJXL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
format, err := parseFormat(formatStr)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return size, format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormat converts a format string to Format.
|
||||||
|
func parseFormat(s string) (Format, error) {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "orig", "original":
|
||||||
|
return FormatOriginal, nil
|
||||||
|
case "jpg", "jpeg":
|
||||||
|
return FormatJPEG, nil
|
||||||
|
case "png":
|
||||||
|
return FormatPNG, nil
|
||||||
|
case "webp":
|
||||||
|
return FormatWebP, nil
|
||||||
|
case "avif":
|
||||||
|
return FormatAVIF, nil
|
||||||
|
case "jxl":
|
||||||
|
return FormatJXL, nil
|
||||||
|
case "gif":
|
||||||
|
return FormatGIF, nil
|
||||||
|
case "auto":
|
||||||
|
return FormatAuto, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared test fixture strings.
|
||||||
|
const (
|
||||||
|
testHostCDN = "cdn.example.com"
|
||||||
|
testHostExample = "example.com"
|
||||||
|
testPathCat = "/photos/cat.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertRequest compares the source host, path and query and the size and
|
||||||
|
// format of got against want.
|
||||||
|
func assertRequest(t *testing.T, got, want *Request) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got.Source.Host != want.Source.Host {
|
||||||
|
t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Source.Path != want.Source.Path {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Source.Query != want.Source.Query {
|
||||||
|
t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Transform.Size != want.Transform.Size {
|
||||||
|
t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Transform.Format != want.Transform.Format {
|
||||||
|
t.Errorf("Format = %q, want %q",
|
||||||
|
got.Transform.Format, want.Transform.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *Request
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic path with size",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size with 0x0",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size with orig keyword",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with query string",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||||
|
},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deep nested path",
|
||||||
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
|
||||||
|
Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jpg alias for jpeg",
|
||||||
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostExample, Path: "/img.png"},
|
||||||
|
Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gif format",
|
||||||
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostExample, Path: "/animated.gif"},
|
||||||
|
Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImageURL(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRequest(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Transform.Format != FormatAuto {
|
||||||
|
t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageURL_Errors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing prefix",
|
||||||
|
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
||||||
|
wantErr: ErrInvalidPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path after prefix",
|
||||||
|
input: "/v1/image/",
|
||||||
|
wantErr: ErrMissingHost,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host only, no path or size",
|
||||||
|
input: "/v1/image/cdn.example.com",
|
||||||
|
wantErr: ErrMissingSize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid size format",
|
||||||
|
input: "/v1/image/cdn.example.com/photo.jpg/invalid.webp",
|
||||||
|
wantErr: ErrInvalidSize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported format",
|
||||||
|
input: "/v1/image/cdn.example.com/photo.jpg/800x600.bmp",
|
||||||
|
wantErr: ErrInvalidFormat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dimension too large",
|
||||||
|
input: "/v1/image/cdn.example.com/photo.jpg/10000x600.webp",
|
||||||
|
wantErr: ErrDimensionTooLarge,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ParseImageURL(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errorIs(err, tt.wantErr) {
|
||||||
|
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImagePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *Request
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "chi wildcard capture",
|
||||||
|
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: testPathCat,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with leading slash from chi",
|
||||||
|
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: testPathCat,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImagePath(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRequest(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
||||||
|
// that every spelling of its letter case is the same host to the allowlist,
|
||||||
|
// the upstream connection limits and the cache, and that the path keeps its
|
||||||
|
// case.
|
||||||
|
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImagePath() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
||||||
|
|
||||||
|
if got.Source.Host != testHostCDN {
|
||||||
|
t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Source.Path != "/Photos/Cat.jpg" {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
||||||
|
// refused: a URL reads what comes before the "@" as a user name and password
|
||||||
|
// and the rest as the host.
|
||||||
|
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
} {
|
||||||
|
got, err := ParseImagePath(input)
|
||||||
|
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
|
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
||||||
|
input, err, ErrInvalidHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// All path traversal attempts should be rejected
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple parent directory",
|
||||||
|
input: "/v1/image/cdn.example.com/../etc/passwd/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double parent directory",
|
||||||
|
input: "/v1/image/cdn.example.com/../../etc/passwd/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent in middle of path",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encoded parent directory",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double encoded parent",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/%252e%252e/secret/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backslash traversal",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/..\\secret/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed slashes",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/../\\../secret/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null byte injection",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/image.jpg%00.png/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent at start of path",
|
||||||
|
input: "/v1/image/cdn.example.com/../800x600.jpeg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ParseImageURL(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ParseImageURL() should reject path traversal attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
|
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test path traversal via ParseImagePath (chi wildcard)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "parent directory in path",
|
||||||
|
input: "cdn.example.com/photos/../secret/800x600.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encoded traversal",
|
||||||
|
input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ParseImagePath(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ParseImagePath() should reject path traversal attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
|
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
||||||
|
// a source query holding %25, the percent-escape of a literal "%", are
|
||||||
|
// accepted as they are, although decoding them once more leaves a "%" that
|
||||||
|
// does not start a percent-escape.
|
||||||
|
func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *Request
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
||||||
|
},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImageURL(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRequest(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImageURL_DoubleEncodedTraversalNextToPercent verifies that a
|
||||||
|
// double-encoded ".." is still refused when the path also holds a "%" that
|
||||||
|
// does not start a percent-escape, either as written or after one more
|
||||||
|
// decoding.
|
||||||
|
func TestParseImageURL_DoubleEncodedTraversalNextToPercent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/50%25off.jpg/800x600.jpeg",
|
||||||
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/100%/800x600.jpeg",
|
||||||
|
} {
|
||||||
|
_, err := ParseImageURL(input)
|
||||||
|
t.Logf("ParseImageURL(%q) error = %v", input, err)
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
|
t.Errorf("ParseImageURL(%q) error = %v, want ErrPathTraversal",
|
||||||
|
input, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorIs checks if err matches target (handles wrapped errors).
|
||||||
|
func errorIs(err, target error) bool {
|
||||||
|
if errors.Is(err, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check if error message contains target message for wrapped errors
|
||||||
|
if err != nil && target != nil {
|
||||||
|
return contains(err.Error(), target.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAt(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
+19
-11
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,35 +10,43 @@ func TestValidateImageRequest(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
req ImageRequest
|
req Request
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "within bounds",
|
name: "within bounds",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, FitMode: FitCover,
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size and empty fit",
|
name: "original size and empty fit",
|
||||||
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
req: Request{Transform: Transform{Size: Size{Width: 0, Height: 0}}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "width over limit",
|
name: "width over limit",
|
||||||
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: MaxDimension + 1, Height: 600},
|
||||||
|
}},
|
||||||
wantErr: ErrDimensionTooLarge,
|
wantErr: ErrDimensionTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "height over limit",
|
name: "height over limit",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: MaxDimension + 1},
|
||||||
|
}},
|
||||||
wantErr: ErrDimensionTooLarge,
|
wantErr: ErrDimensionTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "negative width",
|
name: "negative width",
|
||||||
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
req: Request{Transform: Transform{Size: Size{Width: -1, Height: 600}}},
|
||||||
wantErr: ErrInvalidSize,
|
wantErr: ErrInvalidSize,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid fit mode",
|
name: "invalid fit mode",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, FitMode: "bogus",
|
||||||
|
}},
|
||||||
wantErr: ErrInvalidFitMode,
|
wantErr: ErrInvalidFitMode,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
|
||||||
|
type VariantKey string
|
||||||
|
|
||||||
|
// CacheKey generates a unique key for a request variant.
|
||||||
|
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
|
||||||
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
||||||
|
// either cannot be taken for the one between them.
|
||||||
|
func CacheKey(req *Request) VariantKey {
|
||||||
|
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
|
||||||
|
req.Source.Host,
|
||||||
|
escapePercentAndColon(req.Source.Path),
|
||||||
|
escapePercentAndColon(req.Source.Query),
|
||||||
|
req.Transform.Size.Width,
|
||||||
|
req.Transform.Size.Height,
|
||||||
|
req.Transform.Format,
|
||||||
|
req.Transform.Quality,
|
||||||
|
req.Transform.FitMode,
|
||||||
|
)
|
||||||
|
h := sha256.Sum256([]byte(data))
|
||||||
|
|
||||||
|
return VariantKey(hex.EncodeToString(h[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapePercentAndColon returns s with each "%" written as "%25" and each
|
||||||
|
// ":" as "%3A", so that the result holds no ":".
|
||||||
|
func escapePercentAndColon(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "%", "%25")
|
||||||
|
|
||||||
|
return strings.ReplaceAll(s, ":", "%3A")
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCacheKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req1 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same request should produce same key
|
||||||
|
key1 := CacheKey(req1)
|
||||||
|
key2 := CacheKey(req2)
|
||||||
|
|
||||||
|
if key1 != key2 {
|
||||||
|
t.Errorf("CacheKey() not deterministic: %s vs %s", key1, key2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key should be 64 hex chars
|
||||||
|
if len(string(key1)) != 64 {
|
||||||
|
t.Errorf("CacheKey() length = %d, want 64", len(string(key1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different size should produce different key
|
||||||
|
req3 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 400, Height: 300}, Format: FormatWebP, // Different size
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
key3 := CacheKey(req3)
|
||||||
|
if key1 == key3 {
|
||||||
|
t.Error("CacheKey() produced same key for different sizes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different format should produce different key
|
||||||
|
req4 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatPNG, // Different format
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
key4 := CacheKey(req4)
|
||||||
|
if key1 == key4 {
|
||||||
|
t.Error("CacheKey() produced same key for different formats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different quality should produce different key
|
||||||
|
req5 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
Quality: 50, FitMode: FitCover, // Different quality
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
key5 := CacheKey(req5)
|
||||||
|
if key1 == key5 {
|
||||||
|
t.Error("CacheKey() produced same key for different quality")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
|
||||||
|
// URLs whose path or query holds ":" or "%" get different keys.
|
||||||
|
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path1, query1 string
|
||||||
|
path2, query2 string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "colon moved from path to query",
|
||||||
|
path1: "/a:b", query1: "",
|
||||||
|
path2: "/a", query2: "b:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colon in path and its percent-encoding",
|
||||||
|
path1: "/a:b", query1: "",
|
||||||
|
path2: "/a%3Ab", query2: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key1 := CacheKey(&Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: tt.path1,
|
||||||
|
Query: tt.query1,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
key2 := CacheKey(&Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: tt.path2,
|
||||||
|
Query: tt.query2,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
|
||||||
|
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
|
||||||
|
|
||||||
|
if key1 == key2 {
|
||||||
|
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
|
||||||
|
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
-370
@@ -2,19 +2,17 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache errors.
|
// Cache errors.
|
||||||
@@ -23,17 +21,21 @@ var (
|
|||||||
ErrNegativeCache = errors.New("negative cache hit")
|
ErrNegativeCache = errors.New("negative cache hit")
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP status code for successful fetch.
|
// maxPendingAccessTimes is how many access times of variants served from
|
||||||
const httpStatusOK = 200
|
// the cache, and how many of cached sources a miss used, may each wait to be
|
||||||
|
// written, at about 80 bytes each. An access time handed over while that
|
||||||
|
// many of its kind wait is dropped.
|
||||||
|
const maxPendingAccessTimes = 10000
|
||||||
|
|
||||||
// metaCacheSize is how many variants' content types metaCache holds. A
|
// cleanExpiredBatchSize is the most expired negative cache rows one
|
||||||
// variant not among them is served as before, reading its .meta file.
|
// statement of CleanExpired deletes. Each batch is a statement of its
|
||||||
const metaCacheSize = 10000
|
// own, so a request waits behind one batch at most, however many rows
|
||||||
|
// have expired.
|
||||||
|
const cleanExpiredBatchSize = 1000
|
||||||
|
|
||||||
// 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
|
||||||
@@ -49,8 +51,9 @@ type CacheConfig struct {
|
|||||||
|
|
||||||
// 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 the eviction goroutine only removes the expired
|
||||||
// this when the operator configures cache_max_bytes: 0.
|
// negative cache rows. The config layer sets this when the
|
||||||
|
// operator configures cache_max_bytes: 0.
|
||||||
DisableDiskCache bool
|
DisableDiskCache bool
|
||||||
|
|
||||||
// Logger receives accounting and eviction log output. A nil
|
// Logger receives accounting and eviction log output. A nil
|
||||||
@@ -60,12 +63,11 @@ type CacheConfig struct {
|
|||||||
|
|
||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
srcContent *ContentStorage // source images by content hash
|
srcContent *ContentStorage // source images by source key
|
||||||
variants *VariantStorage // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
config CacheConfig
|
||||||
config CacheConfig
|
log *slog.Logger
|
||||||
log *slog.Logger
|
|
||||||
|
|
||||||
// disabled means the disk cache is turned off entirely: lookups
|
// disabled means the disk cache is turned off entirely: lookups
|
||||||
// always miss, stores are no-ops, and no eviction runs.
|
// always miss, stores are no-ops, and no eviction runs.
|
||||||
@@ -79,23 +81,41 @@ type Cache struct {
|
|||||||
evictionDone chan struct{}
|
evictionDone chan struct{}
|
||||||
evictionCancel context.CancelFunc
|
evictionCancel context.CancelFunc
|
||||||
|
|
||||||
// metaCache holds the content types of the variants most recently
|
// The pending counts: hits, misses, upstream fetches and transforms
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
// that requests counted, kept here until they are written by the
|
||||||
// It never stands in for the variant file, which is always opened.
|
// goroutine that StartPendingCountWrites starts, so that no request
|
||||||
metaCache *lru.Cache[VariantKey, string]
|
// waits for the database to count it. pendingVariantAccessTimes holds
|
||||||
|
// the variants served from the cache, and pendingSourceAccessTimes the
|
||||||
|
// cached sources a miss used, whose access time is still to be written
|
||||||
|
// by that goroutine. As for eviction, the channels are created in
|
||||||
|
// NewCache, so that requests can hand over access times before that
|
||||||
|
// goroutine starts; 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
|
||||||
|
pendingVariantAccessTimes chan imagerequest.VariantKey
|
||||||
|
pendingSourceAccessTimes chan imagerequest.SourceKey
|
||||||
|
pendingCountsDone chan struct{}
|
||||||
|
pendingCountsCancel context.CancelFunc
|
||||||
|
|
||||||
// contentLocks serializes StoreSource and evictSourceBlob per
|
// Held through each write of the pending counts, and by
|
||||||
// content hash, closing the race window between an eviction's row
|
// readHitAndMissCounts to read them
|
||||||
// deletion and its file unlink against a concurrent store of
|
pendingCountsWriteMutex sync.Mutex
|
||||||
// identical content.
|
|
||||||
contentLocks *contentLock
|
|
||||||
|
|
||||||
// evictSourceBlobTestHook, when set, is invoked by evictSourceBlob
|
// reconciliationPageSize is the most rows one read of a content table
|
||||||
// after its row-deletion transaction commits and before the
|
// returns in the reconciliation pass and in Stats. newCache sets it to
|
||||||
// content file is unlinked. It exists solely so tests can
|
// defaultReconciliationPageSize; tests set it smaller.
|
||||||
// deterministically pause inside that window to exercise
|
reconciliationPageSize int
|
||||||
// concurrent stores against it; production code leaves it nil.
|
|
||||||
evictSourceBlobTestHook func(ContentHash)
|
// 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.
|
||||||
@@ -113,11 +133,6 @@ func newCache(
|
|||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &Cache{
|
c := &Cache{
|
||||||
db: db,
|
db: db,
|
||||||
config: config,
|
config: config,
|
||||||
@@ -125,8 +140,12 @@ func newCache(
|
|||||||
disabled: config.DisableDiskCache,
|
disabled: config.DisableDiskCache,
|
||||||
evictionPressure: make(chan struct{}, 1),
|
evictionPressure: make(chan struct{}, 1),
|
||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: metaCache,
|
|
||||||
contentLocks: newContentLock(),
|
pendingVariantAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
|
||||||
|
pendingSourceAccessTimes: make(chan imagerequest.SourceKey, maxPendingAccessTimes),
|
||||||
|
pendingCountsDone: make(chan struct{}),
|
||||||
|
|
||||||
|
reconciliationPageSize: defaultReconciliationPageSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
@@ -141,22 +160,14 @@ func newCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(
|
variants, err := NewVariantStorage(
|
||||||
filepath.Join(config.StateDir, "cache", "variants"), log,
|
filepath.Join(config.StateDir, "cache", "variants"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srcMetadata, err := NewMetadataStorage(
|
|
||||||
filepath.Join(config.StateDir, "cache", "metadata"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.srcContent = srcContent
|
c.srcContent = srcContent
|
||||||
c.variants = variants
|
c.variants = variants
|
||||||
c.srcMetadata = srcMetadata
|
|
||||||
|
|
||||||
if config.UseDefaultMaxBytes {
|
if config.UseDefaultMaxBytes {
|
||||||
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
||||||
@@ -175,146 +186,68 @@ func newCache(
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupResult contains the result of a cache lookup.
|
// GetVariant opens a cached variant and returns a reader, its size and its
|
||||||
type LookupResult struct {
|
// content type, read from the variant file's first bytes. It opens that one
|
||||||
Hit bool
|
// file and makes no database call. A variant not in the cache, or any
|
||||||
CacheKey VariantKey
|
// variant of a disabled cache, is ErrNotFound; a variant file whose first
|
||||||
ContentType string
|
// bytes are not those of an output format is another error.
|
||||||
SizeBytes int64
|
func (c *Cache) GetVariant(
|
||||||
CacheStatus CacheStatus
|
cacheKey imagerequest.VariantKey,
|
||||||
}
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk. Hits touch the
|
|
||||||
// variant's LRU timestamp; a disabled cache always misses.
|
|
||||||
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
|
||||||
cacheKey := CacheKey(req)
|
|
||||||
|
|
||||||
// Check variant storage directly - no DB needed for cache hits
|
|
||||||
if !c.disabled && c.variants.Exists(cacheKey) {
|
|
||||||
c.touchVariant(ctx, cacheKey)
|
|
||||||
|
|
||||||
return &LookupResult{
|
|
||||||
Hit: true,
|
|
||||||
CacheKey: cacheKey,
|
|
||||||
CacheStatus: CacheHit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LookupResult{
|
|
||||||
Hit: false,
|
|
||||||
CacheKey: cacheKey,
|
|
||||||
CacheStatus: CacheMiss,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVariant returns a reader, size, and content type for a cached
|
|
||||||
// variant. The content type comes from metaCache, or else from the
|
|
||||||
// variant's .meta file and is then kept in metaCache. A variant with
|
|
||||||
// no .meta file is served as application/octet-stream, which is not
|
|
||||||
// kept.
|
|
||||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil, 0, "", ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType, known := c.metaCache.Get(cacheKey)
|
return c.variants.LoadWithContentType(cacheKey)
|
||||||
if !known {
|
|
||||||
return c.loadVariantWithMeta(cacheKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
// The file is gone, e.g. deleted outside pixa
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata. On a
|
// StoreSource stores fetched source content: it writes the file named by
|
||||||
// disabled cache it is a no-op returning an empty hash.
|
// the source's key, then the source's row. When the row cannot be written
|
||||||
|
// the file is removed and the store fails, as a file with no row is never
|
||||||
|
// served or evicted; if that removal fails too, the next reconciliation
|
||||||
|
// pass removes the file. On a disabled cache it is a no-op returning an
|
||||||
|
// empty key.
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *imagerequest.Request,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *httpfetcher.FetchResult,
|
) (imagerequest.SourceKey, error) {
|
||||||
) (ContentHash, error) {
|
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the content ourselves (rather than via srcContent.Store,
|
|
||||||
// which would hash internally) so the content hash is known before
|
|
||||||
// any file or database work happens: that lets the entire store be
|
|
||||||
// serialized, per hash, against a concurrent eviction of the same
|
|
||||||
// content below.
|
|
||||||
data, err := io.ReadAll(content)
|
data, err := io.ReadAll(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read source content: %w", err)
|
return "", fmt.Errorf("failed to read source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := sha256.Sum256(data)
|
key := req.Source.Key()
|
||||||
contentHash := ContentHash(hex.EncodeToString(sum[:]))
|
|
||||||
|
|
||||||
// Hold the content hash's lock for the whole store operation. A
|
size, err := c.srcContent.Store(key, data)
|
||||||
// concurrent eviction of this exact hash (the real SHA-256 dedup
|
|
||||||
// case: a different source path whose bytes hash identically)
|
|
||||||
// deletes the accounting rows and unlinks the file inside the same
|
|
||||||
// lock, so the two can never interleave: either this store
|
|
||||||
// completes first (and a subsequent eviction removes it together
|
|
||||||
// with its rows and file, correctly), or eviction completes first
|
|
||||||
// (and this store finds the file already gone and recreates it
|
|
||||||
// fresh) — never a fresh row left pointing at a file eviction is
|
|
||||||
// mid-unlink on.
|
|
||||||
unlock := c.contentLocks.Lock(string(contentHash))
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
size, err := c.srcContent.StoreHashed(contentHash, data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in database
|
|
||||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
|
||||||
|
|
||||||
headersJSON, err := json.Marshal(result.Headers)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
INSERT INTO source_content (source_key, size_bytes)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(content_hash) DO NOTHING
|
ON CONFLICT(source_key) DO UPDATE SET
|
||||||
`, contentHash, result.ContentType, size)
|
size_bytes = excluded.size_bytes,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(key), size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
deleteErr := c.srcContent.Delete(key)
|
||||||
|
if deleteErr != nil {
|
||||||
|
c.log.Warn("failed to remove source file whose row was not written",
|
||||||
|
"source_key", key, "error", deleteErr)
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to insert source content: %w", err)
|
return "", fmt.Errorf("failed to insert source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO source_metadata
|
|
||||||
(source_host, source_path, source_query, path_hash,
|
|
||||||
content_hash, status_code, content_type, response_headers)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
|
||||||
content_hash = excluded.content_hash,
|
|
||||||
status_code = excluded.status_code,
|
|
||||||
content_type = excluded.content_type,
|
|
||||||
response_headers = excluded.response_headers,
|
|
||||||
fetched_at = CURRENT_TIMESTAMP
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery, pathHash,
|
|
||||||
contentHash, httpStatusOK, result.ContentType, string(headersJSON))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.writeMetadataSidecar(req, pathHash, contentHash, result)
|
|
||||||
|
|
||||||
c.notifyWritePressure()
|
c.notifyWritePressure()
|
||||||
|
|
||||||
return contentHash, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key and records
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
@@ -322,27 +255,24 @@ func (c *Cache) StoreSource(
|
|||||||
// accounting insert is best-effort (the startup reconciliation pass
|
// accounting insert is best-effort (the startup reconciliation pass
|
||||||
// adopts any variant file that misses its accounting row).
|
// adopts any variant file that misses its accounting row).
|
||||||
func (c *Cache) StoreVariant(
|
func (c *Cache) StoreVariant(
|
||||||
ctx context.Context, cacheKey VariantKey, content io.Reader, contentType string,
|
ctx context.Context, cacheKey imagerequest.VariantKey, content io.Reader,
|
||||||
) error {
|
) error {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
size, err := c.variants.Store(cacheKey, content, contentType)
|
size, err := c.variants.Store(cacheKey, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Add(cacheKey, contentType)
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
INSERT INTO variant_content (cache_key, size_bytes)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
size_bytes = excluded.size_bytes,
|
size_bytes = excluded.size_bytes,
|
||||||
content_type = excluded.content_type,
|
|
||||||
last_accessed_at = CURRENT_TIMESTAMP
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
`, string(cacheKey), size, contentType)
|
`, string(cacheKey), size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("failed to record variant in size accounting",
|
c.log.Warn("failed to record variant in size accounting",
|
||||||
"cache_key", cacheKey, "error", err)
|
"cache_key", cacheKey, "error", err)
|
||||||
@@ -354,60 +284,54 @@ func (c *Cache) StoreVariant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values
|
// Returns the source key if found, or an empty key if not; a row whose
|
||||||
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
|
// file is missing is not found. A source found has its access time
|
||||||
// reports no cached source.
|
// handed to the background writer without waiting (see
|
||||||
|
// touchSourceContent); a disabled cache always reports no cached source.
|
||||||
func (c *Cache) LookupSource(
|
func (c *Cache) LookupSource(
|
||||||
ctx context.Context, req *ImageRequest,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (ContentHash, string, error) {
|
) (imagerequest.SourceKey, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return "", "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hashStr, contentType string
|
key := req.Source.Key()
|
||||||
|
|
||||||
|
var foundKey string
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT content_hash, content_type FROM source_metadata
|
SELECT source_key FROM source_content WHERE source_key = ?
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
`, string(key)).Scan(&foundKey)
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&hashStr, &contentType)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to lookup source: %w", err)
|
return "", fmt.Errorf("failed to lookup source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHash := ContentHash(hashStr)
|
|
||||||
|
|
||||||
// Verify the content file exists
|
// Verify the content file exists
|
||||||
if !c.srcContent.Exists(contentHash) {
|
if !c.srcContent.Exists(key) {
|
||||||
return "", "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.touchSourceContent(ctx, contentHash)
|
c.touchSourceContent(key)
|
||||||
|
|
||||||
return contentHash, contentType, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
func (c *Cache) StoreNegative(
|
func (c *Cache) StoreNegative(ctx context.Context, req *imagerequest.Request) error {
|
||||||
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
|
|
||||||
) error {
|
|
||||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, source_query, status_code,
|
(source_host, source_path, source_query, expires_at)
|
||||||
error_message, expires_at)
|
VALUES (?, ?, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
||||||
status_code = excluded.status_code,
|
|
||||||
error_message = excluded.error_message,
|
|
||||||
fetched_at = CURRENT_TIMESTAMP,
|
|
||||||
expires_at = excluded.expires_at
|
expires_at = excluded.expires_at
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery, statusCode, errMsg, expiresAt)
|
`, req.Source.Host, req.Source.Path, req.Source.Query, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert negative cache: %w", err)
|
return fmt.Errorf("failed to insert negative cache: %w", err)
|
||||||
}
|
}
|
||||||
@@ -415,64 +339,53 @@ func (c *Cache) StoreNegative(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
// GetSourceContent returns a reader for cached source content by its key,
|
||||||
func (c *Cache) GetSourceMetadataID(
|
|
||||||
ctx context.Context, req *ImageRequest,
|
|
||||||
) (int64, error) {
|
|
||||||
var id int64
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM source_metadata
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSourceContent returns a reader for cached source content by its hash,
|
|
||||||
// and the content's size in bytes.
|
// and the content's size in bytes.
|
||||||
func (c *Cache) GetSourceContent(
|
func (c *Cache) GetSourceContent(
|
||||||
contentHash ContentHash,
|
key imagerequest.SourceKey,
|
||||||
) (io.ReadCloser, int64, error) {
|
) (io.ReadCloser, int64, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.srcContent.LoadWithSize(contentHash)
|
return c.srcContent.LoadWithSize(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired removes expired entries from the cache.
|
// CleanExpired removes the negative cache rows past their expiry, one
|
||||||
|
// batch per statement, until none is left. Once ctx ends it starts no
|
||||||
|
// further batch and returns ctx's error. The eviction goroutine calls it
|
||||||
|
// at startup and on every periodic tick, so a row stays about
|
||||||
|
// NegativeTTL plus one eviction interval, whether or not its URL is
|
||||||
|
// requested again.
|
||||||
func (c *Cache) CleanExpired(ctx context.Context) error {
|
func (c *Cache) CleanExpired(ctx context.Context) error {
|
||||||
// Clean expired negative cache entries
|
for {
|
||||||
_, err := c.db.ExecContext(ctx, `
|
if ctx.Err() != nil {
|
||||||
DELETE FROM negative_cache WHERE expires_at < CURRENT_TIMESTAMP
|
return ctx.Err()
|
||||||
`)
|
}
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to clean negative cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
deleted, err := c.cleanExpiredBatch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted < cleanExpiredBatchSize {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats returns cache statistics.
|
// Stats returns cache statistics.
|
||||||
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)
|
||||||
@@ -482,7 +395,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)
|
||||||
}
|
}
|
||||||
@@ -497,157 +410,91 @@ 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
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
// methods, it only adds to the pending counts, which the goroutine
|
||||||
var err error
|
// StartPendingCountWrites starts writes to the database, so it never waits
|
||||||
|
// for the database or fails.
|
||||||
|
func (c *Cache) IncrementStats(hit bool, fetchBytes int64) {
|
||||||
if hit {
|
if hit {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
c.pendingHits.Add(1)
|
||||||
UPDATE cache_stats
|
|
||||||
SET hit_count = hit_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
} else {
|
} else {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
c.pendingMisses.Add(1)
|
||||||
UPDATE cache_stats
|
|
||||||
SET miss_count = miss_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
c.IncrementUpstreamFetch(fetchBytes)
|
||||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.IncrementUpstreamFetch(ctx, fetchBytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||||
// A fetch that read no bytes is not counted.
|
// A fetch that read no bytes is not counted.
|
||||||
func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
func (c *Cache) IncrementUpstreamFetch(fetchBytes int64) {
|
||||||
if fetchBytes <= 0 {
|
if fetchBytes <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
c.pendingUpstreamFetches.Add(1)
|
||||||
UPDATE cache_stats
|
c.pendingUpstreamFetchBytes.Add(fetchBytes)
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`, fetchBytes)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to count upstream fetch",
|
|
||||||
"fetch_bytes", fetchBytes, "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
func (c *Cache) IncrementTransformCount() {
|
||||||
_, err := c.db.ExecContext(ctx, `
|
c.pendingTransforms.Add(1)
|
||||||
UPDATE cache_stats
|
}
|
||||||
SET transform_count = transform_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
||||||
WHERE id = 1
|
// cache_stats row plus the pending hits and misses (see
|
||||||
`)
|
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
|
||||||
if err != nil {
|
// write of the pending counts falls between the two reads.
|
||||||
c.log.Warn("failed to count transform", "error", err)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchVariant hands the access time of a variant served from the cache to
|
||||||
|
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||||
|
// batch. It never waits: while maxPendingAccessTimes access times of
|
||||||
|
// variants wait to be written, this one is dropped, and the variant only
|
||||||
|
// looks colder to eviction.
|
||||||
|
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
||||||
|
select {
|
||||||
|
case c.pendingVariantAccessTimes <- cacheKey:
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
// touchSourceContent hands the access time of a cached source a miss used to
|
||||||
// hold: it reads the content type from the variant's .meta file and
|
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
// batch. It never waits: while maxPendingAccessTimes access times of sources
|
||||||
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
// wait to be written, this one is dropped, and the source only looks colder
|
||||||
// as one can between a store's writing of the variant file and of its
|
// to eviction.
|
||||||
// .meta file, serves application/octet-stream and keeps nothing, so
|
func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
|
||||||
// metaCache only ever holds a type read from a .meta file or passed to
|
select {
|
||||||
// StoreVariant.
|
case c.pendingSourceAccessTimes <- key:
|
||||||
func (c *Cache) loadVariantWithMeta(
|
default:
|
||||||
cacheKey VariantKey,
|
|
||||||
) (io.ReadCloser, int64, string, error) {
|
|
||||||
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType == "" {
|
|
||||||
return reader, size, fallbackContentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
|
||||||
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
|
||||||
// database.
|
|
||||||
func (c *Cache) writeMetadataSidecar(
|
|
||||||
req *ImageRequest,
|
|
||||||
pathHash PathHash,
|
|
||||||
contentHash ContentHash,
|
|
||||||
result *httpfetcher.FetchResult,
|
|
||||||
) {
|
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: req.SourceHost,
|
|
||||||
Path: req.SourcePath,
|
|
||||||
Query: req.SourceQuery,
|
|
||||||
ContentHash: string(contentHash),
|
|
||||||
StatusCode: result.StatusCode,
|
|
||||||
ContentType: result.ContentType,
|
|
||||||
ContentLength: result.ContentLength,
|
|
||||||
ResponseHeaders: result.Headers,
|
|
||||||
FetchedAt: time.Now().UTC().Unix(),
|
|
||||||
FetchDurationMs: result.FetchDurationMs,
|
|
||||||
RemoteAddr: result.RemoteAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to write metadata sidecar",
|
|
||||||
"host", req.SourceHost, "path_hash", pathHash, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchVariant updates the LRU timestamp of a variant, best-effort:
|
// checkNegativeCache checks if a request is in the negative cache. It only
|
||||||
// a failed touch only makes the entry look colder to eviction.
|
// reads: a row past its expiry is not a hit, and the eviction goroutine
|
||||||
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
|
// deletes it with the other expired rows (see CleanExpired).
|
||||||
_, err := c.db.ExecContext(ctx, `
|
|
||||||
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE cache_key = ?
|
|
||||||
`, string(cacheKey))
|
|
||||||
if err != nil {
|
|
||||||
c.log.Debug("failed to touch variant LRU timestamp",
|
|
||||||
"cache_key", cacheKey, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// touchSourceContent updates the LRU timestamp of a source content
|
|
||||||
// blob, best-effort: a failed touch only makes the blob look colder.
|
|
||||||
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
|
||||||
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE content_hash = ?
|
|
||||||
`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
c.log.Debug("failed to touch source content LRU timestamp",
|
|
||||||
"content_hash", contentHash, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkNegativeCache checks if a request is in the negative cache.
|
|
||||||
func (c *Cache) checkNegativeCache(
|
func (c *Cache) checkNegativeCache(
|
||||||
ctx context.Context, req *ImageRequest,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
var expiresAt time.Time
|
var expiresAt time.Time
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT expires_at FROM negative_cache
|
SELECT expires_at FROM negative_cache
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
`, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&expiresAt)
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -657,20 +504,31 @@ func (c *Cache) checkNegativeCache(
|
|||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
expired := time.Now().After(expiresAt)
|
||||||
if time.Now().After(expiresAt) {
|
|
||||||
// Clean up expired entry
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to delete expired negative cache entry",
|
|
||||||
"host", req.SourceHost, "path", req.SourcePath, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
return !expired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
||||||
|
// rows past their expiry in one statement, and returns how many it
|
||||||
|
// deleted. DELETE ... LIMIT needs a SQLite build option, so the subquery
|
||||||
|
// picks the rows.
|
||||||
|
func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
|
||||||
|
result, err := c.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM negative_cache WHERE id IN (
|
||||||
|
SELECT id FROM negative_cache
|
||||||
|
WHERE expires_at < CURRENT_TIMESTAMP
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
`, cleanExpiredBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to clean negative cache: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
deleted, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count cleaned negative cache rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetVariant_ContentTypeFromFirstBytes stores an image in each output
|
||||||
|
// format as a variant and checks that GetVariant serves it whole with the
|
||||||
|
// content type of its format, read from the file.
|
||||||
|
func TestGetVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
processor := imageprocessor.New(imageprocessor.Params{})
|
||||||
|
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
||||||
|
|
||||||
|
for _, format := range []imagerequest.Format{
|
||||||
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
|
} {
|
||||||
|
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 20, Height: 15},
|
||||||
|
Format: format,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("making a %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := imagerequest.VariantKey("aabbccdd10" + string(format))
|
||||||
|
|
||||||
|
err = cache.StoreVariant(t.Context(), key, bytes.NewReader(image))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreVariant(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, contentType, err := cache.GetVariant(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVariant(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
served, err := io.ReadAll(reader)
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s variant: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
||||||
|
|
||||||
|
if want := imageprocessor.FormatToMIME(format); contentType != want {
|
||||||
|
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(image)) || !bytes.Equal(served, image) {
|
||||||
|
t.Errorf("%s variant served %d bytes, size %d, want the %d bytes stored",
|
||||||
|
format, len(served), size, len(image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetVariant_RefusesFileOfNoOutputFormat checks that GetVariant refuses
|
||||||
|
// a variant file whose first bytes are not those of an output format, with
|
||||||
|
// an error other than ErrNotFound, and that a variant with no file, or any
|
||||||
|
// variant of a disabled cache, is ErrNotFound.
|
||||||
|
func TestGetVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
||||||
|
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
|
errNotAnOutputFormat},
|
||||||
|
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
||||||
|
{"empty", nil, io.EOF},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(key, bytes.NewReader(tt.content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := cache.GetVariant(key)
|
||||||
|
t.Logf("%s: GetVariant() error = %v", tt.name, err)
|
||||||
|
|
||||||
|
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("%s: GetVariant() = reader %v, content type %q, error %v; "+
|
||||||
|
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, _, err := cache.GetVariant(testVariantKeyFour)
|
||||||
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant() of a variant with no file = reader %v, error %v; "+
|
||||||
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, _ := newEvictionTestCache(t, 0)
|
||||||
|
|
||||||
|
reader, _, _, err = disabled.GetVariant(testVariantKeyOne)
|
||||||
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant() on a disabled cache = reader %v, error %v; "+
|
||||||
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_MakesAgainVariantOfNoOutputFormat replaces a cached
|
||||||
|
// variant's file with text, and checks that the next request is a miss
|
||||||
|
// answered with the variant made again, and the one after a hit.
|
||||||
|
func TestService_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"), StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replacing the variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the replacement: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"made again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_HitWaitsForNoDatabaseWrite holds the database's one
|
||||||
|
// connection while a cached variant is requested. The hit must be answered
|
||||||
|
// while the connection is still held, and its access time and hit count
|
||||||
|
// must reach the database once it is free.
|
||||||
|
func TestService_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
key := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
getImage(t, svc, req)
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setVariantLastAccessed(t, svc.cache, key, anHourAgo)
|
||||||
|
|
||||||
|
want := readCacheStatsCounters(t, svc.cache)
|
||||||
|
want.hitCount++
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
results := startGet(t.Context(), svc, req)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("the cache hit waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
waitForCounters(t, svc.cache, want)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingCountsWait)
|
||||||
|
|
||||||
|
for countRows(t, svc.cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the hit's access time was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
|
||||||
|
// more access time of a variant, and one more of a source, than may wait to
|
||||||
|
// be written. touchVariant and touchSourceContent must return each time
|
||||||
|
// without waiting, and the last of each must be dropped.
|
||||||
|
func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
for range maxPendingAccessTimes + 1 {
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
cache.touchSourceContent("aabbccdd3001")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("touchVariant or touchSourceContent waited once the access " +
|
||||||
|
"times reached the limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingVariantAccessTimes); waiting != maxPendingAccessTimes {
|
||||||
|
t.Errorf("%d access times of variants wait to be written, want %d",
|
||||||
|
waiting, maxPendingAccessTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingSourceAccessTimes); waiting != maxPendingAccessTimes {
|
||||||
|
t.Errorf("%d access times of sources wait to be written, want %d",
|
||||||
|
waiting, maxPendingAccessTimes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingCountWrites_WritesPendingAccessTimes hands the access time
|
||||||
|
// of one of two variants, three times, then stops the goroutine that writes
|
||||||
|
// them, which never started. The stop must write that access time and
|
||||||
|
// leave the other variant's.
|
||||||
|
func TestStopPendingCountWrites_WritesPendingAccessTimes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
|
||||||
|
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
|
||||||
|
setVariantLastAccessed(t, cache, key, anHourAgo)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cache.StopPendingCountWrites(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StopPendingCountWrites() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, wantTouched := range map[imagerequest.VariantKey]int{
|
||||||
|
testVariantKeyOne: 1, testVariantKeyTwo: 0,
|
||||||
|
} {
|
||||||
|
touched := countRows(t, cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat))
|
||||||
|
if touched != wantTouched {
|
||||||
|
t.Errorf("variant %s: %d rows with a new access time, want %d",
|
||||||
|
key, touched, wantTouched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingVariantAccessTimes); waiting != 0 {
|
||||||
|
t.Errorf("%d access times still wait to be written, want 0", waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -23,54 +21,15 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
// Create schema
|
// Create schema
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE source_content (
|
CREATE TABLE source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
|
||||||
CREATE TABLE source_metadata (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_host TEXT NOT NULL,
|
|
||||||
source_path TEXT NOT NULL,
|
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
|
||||||
path_hash TEXT NOT NULL,
|
|
||||||
content_hash TEXT,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
content_type TEXT,
|
|
||||||
response_headers TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME,
|
|
||||||
etag TEXT,
|
|
||||||
last_modified TEXT,
|
|
||||||
UNIQUE(source_host, source_path, source_query)
|
|
||||||
);
|
|
||||||
CREATE TABLE output_content (
|
|
||||||
content_hash TEXT PRIMARY KEY,
|
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE TABLE request_cache (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
cache_key TEXT NOT NULL UNIQUE,
|
|
||||||
source_metadata_id INTEGER NOT NULL,
|
|
||||||
output_hash TEXT NOT NULL,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
format TEXT NOT NULL,
|
|
||||||
quality INTEGER NOT NULL DEFAULT 85,
|
|
||||||
fit_mode TEXT NOT NULL DEFAULT 'cover',
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
access_count INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
);
|
||||||
CREATE TABLE negative_cache (
|
CREATE TABLE negative_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
source_query TEXT NOT NULL DEFAULT '',
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
UNIQUE(source_host, source_path, source_query)
|
UNIQUE(source_host, source_path, source_query)
|
||||||
);
|
);
|
||||||
@@ -102,7 +61,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 {
|
||||||
@@ -112,320 +70,6 @@ func setupTestCache(t *testing.T) *Cache {
|
|||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_LookupMiss(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: testPathCat,
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Hit {
|
|
||||||
t.Error("Lookup() hit = true, want false for cache miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.CacheStatus != CacheMiss {
|
|
||||||
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheMiss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_StoreAndLookup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: testPathCat,
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store source content
|
|
||||||
sourceContent := []byte("fake jpeg data")
|
|
||||||
fetchResult := &httpfetcher.FetchResult{
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
|
|
||||||
}
|
|
||||||
|
|
||||||
contentHash, err := cache.StoreSource(
|
|
||||||
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreSource() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentHash == "" {
|
|
||||||
t.Error("StoreSource() returned empty hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := CacheKey(req)
|
|
||||||
outputContent := []byte("fake webp data")
|
|
||||||
|
|
||||||
err = cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now lookup should hit
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Hit {
|
|
||||||
t.Error("Lookup() hit = false, want true after store")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.CacheStatus != CacheHit {
|
|
||||||
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.CacheKey == "" {
|
|
||||||
t.Error("Lookup() returned empty cache key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_NegativeCache(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: "/photos/notfound.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store negative cache entry
|
|
||||||
err := cache.StoreNegative(ctx, req, 404, "not found")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreNegative() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup should return miss (negative cache is checked at service level)
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Hit {
|
|
||||||
t.Error("Lookup() hit = true, want false for negative cache entry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Very short negative TTL for testing
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: tmpDir,
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: "/photos/expired.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store negative cache entry
|
|
||||||
err = cache.StoreNegative(ctx, req, 404, "not found")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreNegative() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for expiry
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
|
|
||||||
// Lookup should return miss after expiry
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v, want nil after expiry", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Hit {
|
|
||||||
t.Error("Lookup() hit = true, want false after negative cache expiry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_VariantLookup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: "/photos/variant.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := CacheKey(req)
|
|
||||||
outputContent := []byte("output data")
|
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First lookup
|
|
||||||
result1, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() first error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result1.Hit {
|
|
||||||
t.Error("Lookup() first hit = false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second lookup should also hit (from disk)
|
|
||||||
result2, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() second error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result2.Hit {
|
|
||||||
t.Error("Lookup() second hit = false")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result1.CacheKey != result2.CacheKey {
|
|
||||||
t.Error("Lookup() returned different cache keys")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: "/photos/variantct.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := CacheKey(req)
|
|
||||||
outputContent := []byte("output webp data")
|
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Hit {
|
|
||||||
t.Fatal("Lookup() hit = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVariant should return content type
|
|
||||||
reader, size, contentType, err := cache.GetVariant(result.CacheKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
if contentType != "image/webp" {
|
|
||||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
|
||||||
}
|
|
||||||
|
|
||||||
if size != int64(len(outputContent)) {
|
|
||||||
t.Errorf("GetVariant() size = %d, want %d", size, len(outputContent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_GetVariant(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: testHostCDN,
|
|
||||||
SourcePath: "/photos/output.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := CacheKey(req)
|
|
||||||
outputContent := []byte("the actual output content")
|
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup to get cache key
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get variant content
|
|
||||||
reader, _, _, err := cache.GetVariant(result.CacheKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
buf := make([]byte, 100)
|
|
||||||
n, _ := reader.Read(buf)
|
|
||||||
|
|
||||||
if !bytes.Equal(buf[:n], outputContent) {
|
|
||||||
t.Errorf("GetVariant() content = %q, want %q", buf[:n], outputContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_Stats(t *testing.T) {
|
func TestCache_Stats(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -433,9 +77,9 @@ func TestCache_Stats(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Increment some stats
|
// Increment some stats
|
||||||
cache.IncrementStats(ctx, true, 0)
|
cache.IncrementStats(true, 0)
|
||||||
cache.IncrementStats(ctx, true, 0)
|
cache.IncrementStats(true, 0)
|
||||||
cache.IncrementStats(ctx, false, 1024)
|
cache.IncrementStats(false, 1024)
|
||||||
|
|
||||||
stats, err := cache.Stats(ctx)
|
stats, err := cache.Stats(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -467,8 +111,8 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
// Insert expired negative cache entry directly
|
// Insert expired negative cache entry directly
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache
|
INSERT INTO negative_cache
|
||||||
(source_host, source_path, source_query, status_code, expires_at)
|
(source_host, source_path, source_query, expires_at)
|
||||||
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
VALUES ('example.com', '/old.jpg', '', datetime('now', '-1 hour'))
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert test data: %v", err)
|
t.Fatalf("failed to insert test data: %v", err)
|
||||||
@@ -520,7 +164,6 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
dirs := []string{
|
dirs := []string{
|
||||||
"cache/sources",
|
"cache/sources",
|
||||||
"cache/variants",
|
"cache/variants",
|
||||||
"cache/metadata",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// Storing a source URL again changes its size, so the total must
|
||||||
|
// follow a statement that changes it.
|
||||||
|
_, err := cache.db.ExecContext(ctx,
|
||||||
|
`UPDATE source_content SET size_bytes = 900 WHERE source_key = ?`,
|
||||||
|
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
|
||||||
|
// variant row twice, once to check it and once to sum it, and every
|
||||||
|
// source row once, as source rows are read only to sum them, 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,
|
||||||
|
imagerequest.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five variant rows, each read once to check it and once to sum it,
|
||||||
|
// and five source rows, read only to sum them.
|
||||||
|
if coveredRows != 15 {
|
||||||
|
t.Errorf("the reads covered %d rows in all, want 15", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,16 +72,12 @@ func (c *Cache) computeDefaultMaxBytes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
|
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
|
||||||
//nolint:gosec // G115: UsageBytes sums file sizes, never negative
|
//nolint:gosec // G115: UsageBytes returns the total cache usage, never negative
|
||||||
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
|
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
|
||||||
|
|
||||||
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||||
computed = min(computed, math.MaxInt64)
|
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 := int64(computed)
|
||||||
limit = max(limit, DefaultCacheMaxBytesFloor)
|
limit = max(limit, DefaultCacheMaxBytesFloor)
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,8 @@ func TestComputeDefaultMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
|
|||||||
|
|
||||||
// The cache now holds those 3 GiB, which leaves 1 GiB free.
|
// The cache now holds those 3 GiB, which leaves 1 GiB free.
|
||||||
_, err = cache.db.ExecContext(t.Context(),
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
`INSERT INTO variant_content (cache_key, size_bytes) VALUES (?, ?)`,
|
||||||
VALUES (?, ?, ?)`,
|
string(testVariantKeyOne), 3221225472,
|
||||||
string(testVariantKeyOne), 3221225472, testContentTypeWebP,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,14 +81,20 @@ func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher)
|
|||||||
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
||||||
// given quality and fit mode. Each call returns a new request, as Get writes
|
// given quality and fit mode. Each call returns a new request, as Get writes
|
||||||
// to the request it is given.
|
// to the request it is given.
|
||||||
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest {
|
func photoVariant(
|
||||||
return &ImageRequest{
|
fixtures *TestFixtures, quality int, fit imagerequest.Fit,
|
||||||
SourceHost: fixtures.GoodHost,
|
) *imagerequest.Request {
|
||||||
SourcePath: testPathPhoto,
|
return &imagerequest.Request{
|
||||||
Size: Size{Width: 50, Height: 25},
|
Source: imagerequest.Source{
|
||||||
Format: FormatJPEG,
|
Host: fixtures.GoodHost,
|
||||||
Quality: quality,
|
Path: testPathPhoto,
|
||||||
FitMode: fit,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 25},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: fit,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +107,7 @@ type getResult struct {
|
|||||||
// startGet calls Get in a goroutine of its own and delivers what it returned
|
// startGet calls Get in a goroutine of its own and delivers what it returned
|
||||||
// on the channel.
|
// on the channel.
|
||||||
func startGet(
|
func startGet(
|
||||||
ctx context.Context, svc *Service, req *ImageRequest,
|
ctx context.Context, svc *Service, req *imagerequest.Request,
|
||||||
) <-chan getResult {
|
) <-chan getResult {
|
||||||
results := make(chan getResult, 1)
|
results := make(chan getResult, 1)
|
||||||
|
|
||||||
@@ -145,7 +153,7 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
pending := make([]<-chan getResult, 0, requests)
|
pending := make([]<-chan getResult, 0, requests)
|
||||||
for range requests {
|
for range requests {
|
||||||
pending = append(pending,
|
pending = append(pending,
|
||||||
startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)))
|
startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover)))
|
||||||
}
|
}
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
@@ -185,6 +193,8 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
|
|
||||||
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
@@ -198,9 +208,11 @@ func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover))
|
lowQuality := startGet(t.Context(), svc,
|
||||||
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitContain))
|
photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
contain := startGet(t.Context(), svc,
|
||||||
|
photoVariant(fixtures, 85, imagerequest.FitContain))
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
select {
|
select {
|
||||||
@@ -252,12 +264,12 @@ func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
||||||
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, FitCover))
|
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
cancelWaiter()
|
cancelWaiter()
|
||||||
@@ -299,11 +311,11 @@ func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
|||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
||||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
cancelFirst()
|
cancelFirst()
|
||||||
@@ -330,8 +342,7 @@ func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
|||||||
// TestService_Get_ConcurrentMissesShareAFailure has several requests for an
|
// TestService_Get_ConcurrentMissesShareAFailure has several requests for an
|
||||||
// image that cannot be served arrive while its fetch is held: the one fetch
|
// image that cannot be served arrive while its fetch is held: the one fetch
|
||||||
// answers all of them with its error. The request after them is answered from
|
// answers all of them with its error. The request after them is answered from
|
||||||
// the negative cache when the failure is kept there, and fetches again when it
|
// the negative cache, where the failure is kept.
|
||||||
// is not.
|
|
||||||
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -345,8 +356,8 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
|||||||
{"upstream answers 404, kept in the negative cache",
|
{"upstream answers 404, kept in the negative cache",
|
||||||
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
|
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
|
||||||
ErrNegativeCached, 1},
|
ErrNegativeCached, 1},
|
||||||
{"source fails the magic byte check, not kept",
|
{"source fails the magic byte check, kept in the negative cache",
|
||||||
"/images/text.png", magic.ErrUnknownFormat, magic.ErrUnknownFormat, 2},
|
"/images/text.png", magic.ErrUnknownFormat, ErrNegativeCached, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -355,9 +366,9 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
|||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
request := func() *ImageRequest {
|
request := func() *imagerequest.Request {
|
||||||
req := photoVariant(fixtures, 85, FitCover)
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
req.SourcePath = tc.path
|
req.Source.Path = tc.path
|
||||||
|
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
@@ -399,7 +410,7 @@ func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
_, err := svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
||||||
}
|
}
|
||||||
@@ -423,7 +434,7 @@ func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case got := <-results:
|
case got := <-results:
|
||||||
@@ -435,6 +446,47 @@ func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_Get_WaiterGetsProcessingTimedOut has a request with no deadline
|
||||||
|
// join the held fetch of a first request with a short one. With the only
|
||||||
|
// processing slot held, the first request's transcode waits for it until the
|
||||||
|
// first request's deadline ends, and stops then. The waiting request, whose
|
||||||
|
// own context has not ended, must get ErrProcessingTimedOut, which the image
|
||||||
|
// routes answer 503 as when pixa is busy.
|
||||||
|
func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
svc.processor = imageprocessor.New(
|
||||||
|
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
holdProcessingSlot(t, svc.processor)
|
||||||
|
|
||||||
|
const firstTimeout = 500 * time.Millisecond
|
||||||
|
|
||||||
|
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
|
||||||
|
defer cancelFirst()
|
||||||
|
|
||||||
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
|
||||||
|
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
t.Logf("first request: Get() error = %v", (<-first).err)
|
||||||
|
|
||||||
|
got := <-waiter
|
||||||
|
t.Logf("waiting request: Get() error = %v", got.err)
|
||||||
|
|
||||||
|
if !errors.Is(got.err, ErrProcessingTimedOut) {
|
||||||
|
t.Errorf("waiting request: Get() error = %v, want %v",
|
||||||
|
got.err, ErrProcessingTimedOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// panickingFetcher panics on every fetch.
|
// panickingFetcher panics on every fetch.
|
||||||
type panickingFetcher struct{}
|
type panickingFetcher struct{}
|
||||||
|
|
||||||
@@ -462,7 +514,7 @@ func TestService_Get_PanicBecomesAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
||||||
@@ -499,7 +551,7 @@ func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
events := transport.Events()
|
events := transport.Events()
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
// contentLock provides per-key mutual exclusion for content-hash keyed
|
|
||||||
// operations. StoreSource and evictSourceBlob each hold a content
|
|
||||||
// hash's lock for the full duration of their file-plus-accounting-row
|
|
||||||
// work, so a store and an eviction racing on identical content bytes
|
|
||||||
// (the real SHA-256 content-addressed dedup case, not a contrived one)
|
|
||||||
// can never interleave: the unlink of an evicted blob's file can never
|
|
||||||
// race the creation of a fresh database row for a concurrently
|
|
||||||
// re-stored copy of the same content. Entries are removed once no
|
|
||||||
// goroutine holds or is waiting for them, so a long-running process
|
|
||||||
// does not accumulate memory proportional to the number of distinct
|
|
||||||
// content hashes it has ever seen.
|
|
||||||
type contentLock struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]*contentLockEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
// contentLockEntry is one key's exclusion lock plus a count of
|
|
||||||
// goroutines currently holding or waiting to acquire it, used to know
|
|
||||||
// when it is safe to remove the entry from the map.
|
|
||||||
type contentLockEntry struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
count int
|
|
||||||
}
|
|
||||||
|
|
||||||
// newContentLock creates an empty contentLock.
|
|
||||||
func newContentLock() *contentLock {
|
|
||||||
return &contentLock{entries: make(map[string]*contentLockEntry)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock acquires exclusive access for key, blocking until it is
|
|
||||||
// available, and returns a function that releases it. The caller must
|
|
||||||
// invoke the returned function exactly once to release the lock.
|
|
||||||
func (c *contentLock) Lock(key string) func() {
|
|
||||||
c.mu.Lock()
|
|
||||||
|
|
||||||
entry, ok := c.entries[key]
|
|
||||||
if !ok {
|
|
||||||
entry = &contentLockEntry{}
|
|
||||||
c.entries[key] = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
entry.mu.Lock()
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
entry.mu.Unlock()
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
|
|
||||||
entry.count--
|
|
||||||
if entry.count == 0 {
|
|
||||||
delete(c.entries, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestContentLockExcludesSameKey verifies that two goroutines locking
|
|
||||||
// the same key never run their critical sections concurrently.
|
|
||||||
func TestContentLockExcludesSameKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lock := newContentLock()
|
|
||||||
|
|
||||||
var (
|
|
||||||
active atomic.Int32
|
|
||||||
maxSeen int32
|
|
||||||
wg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
const goroutines = 20
|
|
||||||
|
|
||||||
wg.Add(goroutines)
|
|
||||||
|
|
||||||
for range goroutines {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
unlock := lock.Lock("same-key")
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
n := active.Add(1)
|
|
||||||
|
|
||||||
for {
|
|
||||||
seen := atomic.LoadInt32(&maxSeen)
|
|
||||||
if n <= seen || atomic.CompareAndSwapInt32(&maxSeen, seen, n) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
|
|
||||||
active.Add(-1)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if maxSeen != 1 {
|
|
||||||
t.Errorf("max concurrent holders of the same key = %d, want 1", maxSeen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestContentLockAllowsDifferentKeys verifies that locking distinct
|
|
||||||
// keys does not serialize unrelated work: all goroutines must be able
|
|
||||||
// to enter their critical sections at once, proven by every one of
|
|
||||||
// them reaching the rendezvous point before any is allowed to
|
|
||||||
// proceed.
|
|
||||||
func TestContentLockAllowsDifferentKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lock := newContentLock()
|
|
||||||
|
|
||||||
const goroutines = 20
|
|
||||||
|
|
||||||
var (
|
|
||||||
wg sync.WaitGroup
|
|
||||||
inside atomic.Int32
|
|
||||||
reached = make(chan struct{}, goroutines)
|
|
||||||
)
|
|
||||||
|
|
||||||
wg.Add(goroutines)
|
|
||||||
|
|
||||||
release := make(chan struct{})
|
|
||||||
|
|
||||||
for i := range goroutines {
|
|
||||||
key := string(rune('a' + i))
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
unlock := lock.Lock(key)
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
inside.Add(1)
|
|
||||||
|
|
||||||
reached <- struct{}{}
|
|
||||||
|
|
||||||
<-release
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every goroutine must reach the rendezvous point (i.e. acquire its
|
|
||||||
// own key's lock) without needing any other to release first. If
|
|
||||||
// keys were incorrectly serialized onto one underlying lock, only
|
|
||||||
// one would get here and this would time out.
|
|
||||||
for i := range goroutines {
|
|
||||||
select {
|
|
||||||
case <-reached:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("only %d/%d goroutines locking distinct keys made progress; "+
|
|
||||||
"keys may be incorrectly serialized", i, goroutines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := inside.Load(); n != goroutines {
|
|
||||||
t.Errorf("goroutines inside their critical section = %d, want %d",
|
|
||||||
n, goroutines)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestContentLockRemovesEntryAfterUnlock verifies that the internal
|
|
||||||
// entries map does not grow without bound: once no goroutine holds or
|
|
||||||
// awaits a key, its entry is removed.
|
|
||||||
func TestContentLockRemovesEntryAfterUnlock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lock := newContentLock()
|
|
||||||
|
|
||||||
unlock := lock.Lock("k")
|
|
||||||
|
|
||||||
lock.mu.Lock()
|
|
||||||
if _, ok := lock.entries["k"]; !ok {
|
|
||||||
lock.mu.Unlock()
|
|
||||||
t.Fatal("entry missing while lock is held")
|
|
||||||
}
|
|
||||||
lock.mu.Unlock()
|
|
||||||
|
|
||||||
unlock()
|
|
||||||
|
|
||||||
lock.mu.Lock()
|
|
||||||
defer lock.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := lock.entries["k"]; ok {
|
|
||||||
t.Error("entry for key still present after the last holder unlocked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+389
-333
@@ -3,13 +3,15 @@ package imgcache
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultEvictionInterval is how often the background evictor checks
|
// DefaultEvictionInterval is how often the background evictor checks
|
||||||
@@ -17,10 +19,24 @@ import (
|
|||||||
// write-pressure wakeups triggered by stores.
|
// write-pressure wakeups triggered by stores.
|
||||||
const DefaultEvictionInterval = 5 * time.Minute
|
const DefaultEvictionInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// reconciliationWaitFactor is how many times as long as a
|
||||||
|
// reconciliation pass took the eviction loop waits at least, after that
|
||||||
|
// pass ends, before it starts the next one. A pass reads a row and stats a
|
||||||
|
// file for every cached file, so its time grows with the cache; waiting
|
||||||
|
// ten times as long keeps reconciliation to at most a tenth of the time,
|
||||||
|
// however large the cache is.
|
||||||
|
const reconciliationWaitFactor = 10
|
||||||
|
|
||||||
// evictionBatchSize is how many LRU candidates of each class (variants
|
// evictionBatchSize is how many LRU candidates of each class (variants
|
||||||
// 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.
|
||||||
@@ -35,17 +51,11 @@ const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
|||||||
// writes (".tmp-*" patterns in the storage layer).
|
// writes (".tmp-*" patterns in the storage layer).
|
||||||
const tempFilePrefix = ".tmp-"
|
const tempFilePrefix = ".tmp-"
|
||||||
|
|
||||||
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
|
||||||
// to each variant file.
|
|
||||||
const variantMetaSuffix = ".meta"
|
|
||||||
|
|
||||||
// fallbackContentType is the content type given to a variant file that
|
|
||||||
// has no readable .meta sidecar, when it is served or reconciled.
|
|
||||||
const fallbackContentType = "application/octet-stream"
|
|
||||||
|
|
||||||
// UsageBytes returns the total number of bytes of cache content
|
// 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 +63,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
|
||||||
@@ -66,11 +75,11 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
// evictionCandidate is one LRU eviction victim candidate: either a
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
||||||
// processed variant (isVariant true, identified by cacheKey) or a
|
// processed variant (isVariant true, identified by cacheKey) or a
|
||||||
// source content blob (identified by contentHash).
|
// source content blob (identified by sourceKey).
|
||||||
type evictionCandidate struct {
|
type evictionCandidate struct {
|
||||||
isVariant bool
|
isVariant bool
|
||||||
cacheKey VariantKey
|
cacheKey imagerequest.VariantKey
|
||||||
contentHash ContentHash
|
sourceKey imagerequest.SourceKey
|
||||||
sizeBytes int64
|
sizeBytes int64
|
||||||
lastAccessedAt string
|
lastAccessedAt string
|
||||||
}
|
}
|
||||||
@@ -142,7 +151,7 @@ func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error
|
|||||||
|
|
||||||
c.log.Warn("failed to evict cache entry",
|
c.log.Warn("failed to evict cache entry",
|
||||||
"cache_key", candidate.cacheKey,
|
"cache_key", candidate.cacheKey,
|
||||||
"content_hash", candidate.contentHash,
|
"source_key", candidate.sourceKey,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,7 +170,7 @@ func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate)
|
|||||||
return c.evictVariant(ctx, candidate.cacheKey)
|
return c.evictVariant(ctx, candidate.cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.evictSourceBlob(ctx, candidate.contentHash)
|
return c.evictSourceBlob(ctx, candidate.sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictionCandidates returns up to evictionBatchSize variants and
|
// evictionCandidates returns up to evictionBatchSize variants and
|
||||||
@@ -224,7 +233,7 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
|
|||||||
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.cacheKey = VariantKey(key)
|
candidate.cacheKey = imagerequest.VariantKey(key)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,16 +245,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
|
||||||
|
// SQLite reads the rows in order from that column's index instead of
|
||||||
|
// sorting the whole table.
|
||||||
|
const sourceCandidatesQuery = `
|
||||||
|
SELECT source_key, size_bytes, last_accessed_at
|
||||||
|
FROM source_content
|
||||||
|
ORDER BY last_accessed_at ASC, source_key ASC
|
||||||
|
LIMIT ?`
|
||||||
|
|
||||||
|
// sourceCandidates returns the least recently used source blobs.
|
||||||
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
rows, err := c.db.QueryContext(ctx, `
|
rows, err := c.db.QueryContext(ctx, sourceCandidatesQuery, evictionBatchSize)
|
||||||
SELECT content_hash, size_bytes,
|
|
||||||
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
|
||||||
FROM source_content
|
|
||||||
ORDER BY lru ASC, content_hash ASC
|
|
||||||
LIMIT ?
|
|
||||||
`, evictionBatchSize)
|
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
@@ -257,14 +269,14 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate evictionCandidate
|
var candidate evictionCandidate
|
||||||
|
|
||||||
var hash string
|
var key string
|
||||||
|
|
||||||
err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.contentHash = ContentHash(hash)
|
candidate.sourceKey = imagerequest.SourceKey(key)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,19 +289,16 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evictVariant removes one variant: accounting row first, then the
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
// content and .meta files, so the database never references a deleted
|
// file, so the database never references a deleted file.
|
||||||
// file. The metaCache entry goes before the files; a GetVariant that
|
func (c *Cache) evictVariant(
|
||||||
// read them just before may put it back, and the next GetVariant then
|
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||||
// fails to open the file and removes it again.
|
) error {
|
||||||
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
|
||||||
_, err := c.db.ExecContext(ctx,
|
_, err := c.db.ExecContext(ctx,
|
||||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
err = c.variants.Delete(cacheKey)
|
err = c.variants.Delete(cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -298,117 +307,18 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceReference identifies one source_metadata row's JSON sidecar.
|
// evictSourceBlob removes one source: its row first, then its file, as
|
||||||
type sourceReference struct {
|
// evictVariant does for a variant. It holds no lock: a store of the same
|
||||||
host string
|
// source in between can leave the source's row with no file, which
|
||||||
pathHash PathHash
|
// LookupSource treats as a miss.
|
||||||
}
|
func (c *Cache) evictSourceBlob(ctx context.Context, key imagerequest.SourceKey) error {
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
// evictSourceBlob removes one source content blob. All source_metadata
|
`DELETE FROM source_content WHERE source_key = ?`, string(key))
|
||||||
// rows referencing the blob are deleted together with its
|
|
||||||
// source_content row in a single transaction BEFORE the file is
|
|
||||||
// unlinked: a blob referenced by multiple source paths is only ever
|
|
||||||
// removed together with all of its references, and database rows never
|
|
||||||
// point at deleted files. The JSON metadata sidecars for the removed
|
|
||||||
// rows are deleted afterwards.
|
|
||||||
//
|
|
||||||
// The whole operation holds the content hash's lock (the same one
|
|
||||||
// StoreSource holds for its full store), so a concurrent store of
|
|
||||||
// identical content bytes can never observe the file gone but a row
|
|
||||||
// still present, or insert a fresh row between this transaction's
|
|
||||||
// commit and the file unlink below: it either runs entirely before
|
|
||||||
// this eviction starts, or is blocked until this eviction (row
|
|
||||||
// deletion and unlink together) has fully completed.
|
|
||||||
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
|
||||||
unlock := c.contentLocks.Lock(string(contentHash))
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
references, err := c.sourceReferences(ctx, contentHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := c.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete source content row: %w", err)
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
return c.srcContent.Delete(key)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.evictSourceBlobTestHook != nil {
|
|
||||||
c.evictSourceBlobTestHook(contentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only after the rows are gone may the files be removed.
|
|
||||||
for _, reference := range references {
|
|
||||||
err := c.srcMetadata.Delete(reference.host, reference.pathHash)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to delete metadata sidecar",
|
|
||||||
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.srcContent.Delete(contentHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceReferences lists the metadata sidecar locations of every
|
|
||||||
// source_metadata row referencing the given blob.
|
|
||||||
func (c *Cache) sourceReferences(
|
|
||||||
ctx context.Context, contentHash ContentHash,
|
|
||||||
) ([]sourceReference, error) {
|
|
||||||
rows, err := c.db.QueryContext(ctx, `
|
|
||||||
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
|
||||||
`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query source references: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var references []sourceReference
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var reference sourceReference
|
|
||||||
|
|
||||||
var pathHash string
|
|
||||||
|
|
||||||
err := rows.Scan(&reference.host, &pathHash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reference.pathHash = PathHash(pathHash)
|
|
||||||
references = append(references, reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return references, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyWritePressure wakes the background evictor after a store, so
|
// notifyWritePressure wakes the background evictor after a store, so
|
||||||
@@ -427,14 +337,18 @@ func (c *Cache) notifyWritePressure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StartEviction launches the background eviction goroutine, which
|
// StartEviction launches the background eviction goroutine, which
|
||||||
// reconciles the database accounting with the cache directories at
|
// removes the expired negative cache rows at startup and again on every
|
||||||
// startup and again on every periodic tick thereafter, and evicts to
|
// periodic tick thereafter, reconciles the database accounting with the
|
||||||
// the configured limit on the given periodic interval and on
|
// cache directories at startup and again on the first periodic tick
|
||||||
// write-pressure notifications. It is a no-op on a disabled cache or
|
// that comes at least ten times as long after a reconciliation pass
|
||||||
// when already started. The goroutine outlives the caller, so it runs
|
// ended as that pass took, and evicts to the configured limit on the given
|
||||||
// with its own context, which StopEviction cancels.
|
// periodic interval and on write-pressure notifications. On a
|
||||||
|
// disabled cache it only removes the expired negative cache rows, as
|
||||||
|
// the negative cache is used either way. It is a no-op when already
|
||||||
|
// started. The goroutine outlives the caller, so it runs with its own
|
||||||
|
// context, which StopEviction cancels.
|
||||||
func (c *Cache) StartEviction(interval time.Duration) {
|
func (c *Cache) StartEviction(interval time.Duration) {
|
||||||
if c.disabled || c.evictionCancel != nil {
|
if c.evictionCancel != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +383,8 @@ func (c *Cache) StopEviction(ctx context.Context) error {
|
|||||||
func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
||||||
defer close(c.evictionDone)
|
defer close(c.evictionDone)
|
||||||
|
|
||||||
c.runReconciliationPass(ctx)
|
nextReconciliation := c.runReconciliationPass(ctx)
|
||||||
|
c.runCleanExpired(ctx)
|
||||||
c.runEvictionPass(ctx)
|
c.runEvictionPass(ctx)
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
@@ -480,16 +395,21 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Reconciliation walks the cache directories, so it only
|
// Reconciliation walks the cache directories and reads a
|
||||||
// runs on the periodic ticker rather than on every
|
// row for every file, so it runs only on the periodic
|
||||||
// write-pressure wakeup, keeping it off the per-store hot
|
// ticker rather than on every write-pressure wakeup, and
|
||||||
// path. Reusing the eviction interval itself (rather than a
|
// only on the first tick at or after nextReconciliation,
|
||||||
// separate, longer one) is a deliberate choice: it is the
|
// which is ten times as long after the last pass ended as
|
||||||
// simplest option that still bounds how long a store's
|
// that pass took. Passes then never run back to back,
|
||||||
// best-effort accounting insert can stay silently
|
// however long one takes. A file whose best-effort
|
||||||
// unaccounted for to one interval, on a process that is
|
// accounting insert failed in a store stays unaccounted for
|
||||||
// already running this loop regardless.
|
// until the next pass, up to about one eviction interval
|
||||||
c.runReconciliationPass(ctx)
|
// plus ten times the last pass's time.
|
||||||
|
if !time.Now().Before(nextReconciliation) {
|
||||||
|
nextReconciliation = c.runReconciliationPass(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.runCleanExpired(ctx)
|
||||||
case <-c.evictionPressure:
|
case <-c.evictionPressure:
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,31 +432,58 @@ func (c *Cache) runEvictionPass(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runReconciliationPass runs one reconciliation pass, logging failures
|
// runReconciliationPass runs one reconciliation pass, logging failures
|
||||||
// instead of propagating them (the loop must keep running). It does
|
// instead of propagating them (the loop must keep running), and returns
|
||||||
// nothing once ctx is cancelled.
|
// the earliest time the next pass may start: reconciliationWaitFactor
|
||||||
func (c *Cache) runReconciliationPass(ctx context.Context) {
|
// times as long after this pass ended as it took. It does nothing once
|
||||||
|
// ctx is cancelled.
|
||||||
|
func (c *Cache) runReconciliationPass(ctx context.Context) time.Time {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
err := c.reconcileAccounting(ctx)
|
err := c.reconcileAccounting(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ended := time.Now()
|
||||||
|
|
||||||
|
return ended.Add(reconciliationWaitFactor * ended.Sub(started))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCleanExpired runs CleanExpired, logging a failure instead of
|
||||||
|
// propagating it (the loop must keep running). It does nothing once ctx
|
||||||
|
// is cancelled.
|
||||||
|
func (c *Cache) runCleanExpired(ctx context.Context) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.CleanExpired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("negative cache cleanup failed", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileAccounting synchronizes the database size accounting with
|
// reconcileAccounting synchronizes the database size accounting with
|
||||||
// the actual contents of the cache directories. It runs at startup and
|
// the actual contents of the cache directories. It runs at startup and
|
||||||
// again on every periodic eviction tick thereafter, off the request
|
// then on the first periodic eviction tick that comes at least ten times
|
||||||
|
// as long after the last pass ended as that pass took, off the request
|
||||||
// hot path: it adopts variant files that predate the accounting table
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
// (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 variant accounting rows
|
||||||
// files are missing, removes source blob files the database does not
|
// whose files are missing, removes source blob files the database does
|
||||||
// know (and rows whose files are gone), and sweeps stale temp files
|
// not know, removes source and variant files that are empty or not the
|
||||||
// left behind by crashed writes. Running it periodically, not just
|
// size their rows record, sweeps stale temp files left behind by crashed
|
||||||
// once, bounds how long such drift can accumulate unaccounted for on a
|
// writes, and last checks the total cache usage against the tables. It
|
||||||
// long-running process to one eviction interval. Once ctx is cancelled,
|
// reads the tables a page at a time.
|
||||||
// it stops at the next file or row and returns ctx's error.
|
// Running it periodically, not just once, bounds how long such drift can
|
||||||
|
// accumulate unaccounted for on a long-running process to about one
|
||||||
|
// eviction interval plus ten times the last pass's time.
|
||||||
|
// 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
|
||||||
@@ -557,19 +504,19 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.reconcileSourceRows(ctx)
|
return c.reconcileUsageTotal(ctx)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileVariantFiles walks the variant storage directory, adopting
|
// reconcileFiles walks baseDir, one of the cache's storage directories,
|
||||||
// files without accounting rows and sweeping stale temp files.
|
// sweeping stale temp files and passing each other file, with its name, to
|
||||||
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
// reconcileFile. Once ctx is cancelled it stops at the next file and
|
||||||
|
// returns ctx's error.
|
||||||
|
func (c *Cache) reconcileFiles(
|
||||||
|
ctx context.Context, baseDir string,
|
||||||
|
reconcileFile func(path string, entry fs.DirEntry, name string) error,
|
||||||
|
) error {
|
||||||
return filepath.WalkDir(
|
return filepath.WalkDir(
|
||||||
c.variants.baseDir,
|
baseDir,
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -587,46 +534,67 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(name, variantMetaSuffix) {
|
return reconcileFile(path, entry, name)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptVariantFile inserts an accounting row for a variant file that
|
// reconcileVariantFiles walks the variant storage directory, checking
|
||||||
// has none, using the file's size and modification time.
|
// each variant file against its accounting row (reconcileVariantFile)
|
||||||
func (c *Cache) adoptVariantFile(
|
// and sweeping stale temp files.
|
||||||
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
|
return c.reconcileFiles(ctx, c.variants.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, name string) error {
|
||||||
|
return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantFile checks one variant file against its accounting
|
||||||
|
// row. A file without a row is adopted into the accounting, unless it is
|
||||||
|
// empty. A file that is empty, or not the size its row records, as a
|
||||||
|
// crash or a restored snapshot can leave it, is removed with its row and
|
||||||
|
// a warning naming it, so the variant is converted again.
|
||||||
|
func (c *Cache) reconcileVariantFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey imagerequest.VariantKey,
|
||||||
) error {
|
) error {
|
||||||
var rowExists int
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
|
||||||
).Scan(&rowExists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowExists > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := entry.Info()
|
info, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to stat variant file: %w", err)
|
return fmt.Errorf("failed to stat variant file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
var recordedSize int64
|
||||||
contentType := c.variantContentTypeFromSidecar(path)
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
err = c.db.QueryRowContext(ctx,
|
||||||
INSERT INTO variant_content
|
`SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
||||||
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
).Scan(&recordedSize)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
||||||
|
return c.adoptVariantFile(ctx, info, cacheKey)
|
||||||
|
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||||
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is empty, or not the size its row records
|
||||||
|
c.log.Warn("removing damaged cached variant file", "path", path,
|
||||||
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
|
return c.evictVariant(ctx, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
|
// has none, using the file's size and modification time.
|
||||||
|
func (c *Cache) adoptVariantFile(
|
||||||
|
ctx context.Context, info fs.FileInfo, cacheKey imagerequest.VariantKey,
|
||||||
|
) error {
|
||||||
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content (cache_key, size_bytes, last_accessed_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, string(cacheKey), info.Size(), modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
||||||
}
|
}
|
||||||
@@ -637,66 +605,66 @@ func (c *Cache) adoptVariantFile(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// variantContentTypeFromSidecar reads the content type from a variant
|
|
||||||
// .meta sidecar, falling back to application/octet-stream.
|
|
||||||
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
|
||||||
//nolint:gosec // path from cache walk
|
|
||||||
metaData, err := os.ReadFile(variantPath + variantMetaSuffix)
|
|
||||||
if err != nil {
|
|
||||||
return fallbackContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
var meta VariantMeta
|
|
||||||
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
|
||||||
return fallbackContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta.ContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileVariantRows drops accounting rows whose variant files are
|
// 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 imagerequest.VariantKey
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range keys {
|
for {
|
||||||
if ctx.Err() != nil {
|
keys, err := c.variantKeysAfter(ctx, after)
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.variants.Exists(key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx,
|
|
||||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
if c.reconciliationReadTestHook != nil {
|
||||||
|
c.reconciliationReadTestHook(len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.variants.Exists(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
after = keys[len(keys)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 imagerequest.VariantKey,
|
||||||
|
) ([]imagerequest.VariantKey, error) {
|
||||||
|
return queryStringColumn[imagerequest.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)
|
||||||
}
|
}
|
||||||
@@ -724,105 +692,193 @@ func queryStringColumn[T ~string](
|
|||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceFiles walks the source content directory, removing
|
// reconcileSourceFiles walks the source content directory, checking
|
||||||
// blob files the database does not track (they are unreachable: source
|
// each blob file against its source_content row (reconcileSourceFile)
|
||||||
// lookups always go through source_metadata) and sweeping stale temp
|
// and sweeping stale temp files.
|
||||||
// files.
|
|
||||||
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
return filepath.WalkDir(
|
return c.reconcileFiles(ctx, c.srcContent.baseDir,
|
||||||
c.srcContent.baseDir,
|
func(path string, entry fs.DirEntry, name string) error {
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
return c.reconcileSourceFile(ctx, path, entry, imagerequest.SourceKey(name))
|
||||||
if ctx.Err() != nil {
|
})
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || entry.IsDir() {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
|
|
||||||
if strings.HasPrefix(name, tempFilePrefix) {
|
|
||||||
c.sweepStaleTempFile(path, entry)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeUntrackedSourceFile deletes a source blob file that has no
|
// reconcileSourceFile checks one source blob file against its
|
||||||
// source_content row. Any source_metadata rows referencing the hash
|
// source_content row. A file without a row is removed, as no lookup
|
||||||
// are removed first so no row ever points at a deleted file.
|
// serves it and no eviction removes it. A file that is empty, or not the
|
||||||
func (c *Cache) removeUntrackedSourceFile(
|
// size its row records, as a crash or a restored snapshot can leave it,
|
||||||
ctx context.Context, path string, contentHash ContentHash,
|
// is removed with its row and a warning naming it, so the source is
|
||||||
|
// fetched again.
|
||||||
|
func (c *Cache) reconcileSourceFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, key imagerequest.SourceKey,
|
||||||
) error {
|
) error {
|
||||||
var rowExists int
|
info, err := entry.Info()
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
|
||||||
).Scan(&rowExists)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check source content row: %w", err)
|
return fmt.Errorf("failed to stat source content file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rowExists > 0 {
|
var recordedSize int64
|
||||||
|
|
||||||
|
err = c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT size_bytes FROM source_content WHERE source_key = ?`,
|
||||||
|
string(key),
|
||||||
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return c.removeUntrackedSourceFile(path, key)
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx,
|
// The file is empty, or not the size its row records
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
c.log.Warn("removing damaged cached source file", "path", path,
|
||||||
if err != nil {
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Remove(path)
|
return c.evictSourceBlob(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
|
// source_content row.
|
||||||
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
|
path string, key imagerequest.SourceKey,
|
||||||
|
) error {
|
||||||
|
err := os.Remove(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
c.log.Info("removed untracked source content file", "source_key", key)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceRows removes source_content rows (and their metadata
|
// Each of these queries sums size_bytes over the next page of rows of
|
||||||
// references and sidecars) whose blob files are missing on disk.
|
// one content table, the rows that sort after a key, and returns the
|
||||||
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
// page's last key, the sum and the number of rows in the page. Past the
|
||||||
hashes, err := c.allSourceContentHashes(ctx)
|
// last row the page has no rows and the key is NULL.
|
||||||
|
const (
|
||||||
|
sourceSizePageQuery = `
|
||||||
|
SELECT MAX(source_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
|
FROM (
|
||||||
|
SELECT source_key, size_bytes FROM source_content
|
||||||
|
WHERE source_key > ? ORDER BY source_key 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, hash := range hashes {
|
if sumBytes == totalBytes {
|
||||||
if ctx.Err() != nil {
|
return nil
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.srcContent.Exists(hash) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The blob file is already gone; evictSourceBlob removes the
|
|
||||||
// rows and sidecars and tolerates the missing file.
|
|
||||||
err := c.evictSourceBlob(ctx, hash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
corrected, err := c.correctUsageTotal(ctx, sumBytes, changeCount)
|
||||||
|
if err != nil || !corrected {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Warn("corrected total cache usage to the sum of the content tables",
|
||||||
|
"previous_usage_bytes", totalBytes, "usage_bytes", sumBytes)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// allSourceContentHashes returns every tracked source content hash.
|
// correctUsageTotal sets the total cache usage to sumBytes, a sum of the
|
||||||
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
// content tables taken when the change count was changeCount, and
|
||||||
return queryStringColumn[ContentHash](ctx, c.db,
|
// reports whether it did. If a row was added, removed or resized since,
|
||||||
`SELECT content_hash FROM source_content`,
|
// the count has moved and the total is left alone: the sum may have
|
||||||
"source content hashes", "content hash")
|
// 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
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
"modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
||||||
@@ -27,10 +28,10 @@ const sqliteTimestampFormat = "2006-01-02 15:04:05"
|
|||||||
// keys reused across the eviction tests, in the order the tests store
|
// keys reused across the eviction tests, in the order the tests store
|
||||||
// them.
|
// them.
|
||||||
const (
|
const (
|
||||||
testVariantKeyOne VariantKey = "aabbccdd0001"
|
testVariantKeyOne imagerequest.VariantKey = "aabbccdd0001"
|
||||||
testVariantKeyTwo VariantKey = "aabbccdd0002"
|
testVariantKeyTwo imagerequest.VariantKey = "aabbccdd0002"
|
||||||
testVariantKeyThree VariantKey = "aabbccdd0003"
|
testVariantKeyThree imagerequest.VariantKey = "aabbccdd0003"
|
||||||
testVariantKeyFour VariantKey = "aabbccdd0004"
|
testVariantKeyFour imagerequest.VariantKey = "aabbccdd0004"
|
||||||
)
|
)
|
||||||
|
|
||||||
// evictionTestDB creates an in-memory SQLite database with the real
|
// evictionTestDB creates an in-memory SQLite database with the real
|
||||||
@@ -70,7 +71,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,
|
||||||
@@ -83,47 +83,42 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestSource stores content as a fetched source for
|
// storeEvictionTestSource stores content as a fetched source for
|
||||||
// host/path and returns the resulting content hash.
|
// host/path and returns the resulting source key.
|
||||||
func storeEvictionTestSource(
|
func storeEvictionTestSource(
|
||||||
t *testing.T, cache *Cache, host, path string, content []byte,
|
t *testing.T, cache *Cache, host, path string, content []byte,
|
||||||
) ContentHash {
|
) imagerequest.SourceKey {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &imagerequest.Request{
|
||||||
SourceHost: host,
|
Source: imagerequest.Source{
|
||||||
SourcePath: path,
|
Host: host,
|
||||||
Format: FormatJPEG,
|
Path: path,
|
||||||
Quality: 85,
|
},
|
||||||
FitMode: FitCover,
|
Transform: imagerequest.Transform{
|
||||||
}
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
result := &httpfetcher.FetchResult{
|
FitMode: imagerequest.FitCover,
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: int64(len(content)),
|
|
||||||
Headers: map[string][]string{
|
|
||||||
testHeaderContentType: {testContentTypeJPEG},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := cache.StoreSource(
|
key, err := cache.StoreSource(
|
||||||
context.Background(), req, bytes.NewReader(content), result,
|
context.Background(), req, bytes.NewReader(content),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestVariant stores content as a processed variant under
|
// storeEvictionTestVariant stores content as a processed variant under
|
||||||
// the given cache key.
|
// the given cache key.
|
||||||
func storeEvictionTestVariant(
|
func storeEvictionTestVariant(
|
||||||
t *testing.T, cache *Cache, key VariantKey, content []byte,
|
t *testing.T, cache *Cache, key imagerequest.VariantKey, content []byte,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
err := cache.StoreVariant(t.Context(), key, bytes.NewReader(content), "image/webp")
|
err := cache.StoreVariant(t.Context(), key, bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
|
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
|
||||||
}
|
}
|
||||||
@@ -132,7 +127,7 @@ func storeEvictionTestVariant(
|
|||||||
// setVariantLastAccessed backdates the last access time of a tracked
|
// setVariantLastAccessed backdates the last access time of a tracked
|
||||||
// variant, to make LRU ordering deterministic in tests.
|
// variant, to make LRU ordering deterministic in tests.
|
||||||
func setVariantLastAccessed(
|
func setVariantLastAccessed(
|
||||||
t *testing.T, cache *Cache, key VariantKey, when time.Time,
|
t *testing.T, cache *Cache, key imagerequest.VariantKey, when time.Time,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -158,13 +153,13 @@ func setVariantLastAccessed(
|
|||||||
// setSourceLastAccessed backdates the last access time of a tracked
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
// source content blob.
|
// source content blob.
|
||||||
func setSourceLastAccessed(
|
func setSourceLastAccessed(
|
||||||
t *testing.T, cache *Cache, hash ContentHash, when time.Time,
|
t *testing.T, cache *Cache, key imagerequest.SourceKey, when time.Time,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := cache.db.ExecContext(t.Context(),
|
res, err := cache.db.ExecContext(t.Context(),
|
||||||
`UPDATE source_content SET last_accessed_at = ? WHERE content_hash = ?`,
|
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
||||||
when.UTC().Format(sqliteTimestampFormat), string(hash),
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
@@ -176,7 +171,7 @@ func setSourceLastAccessed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if affected != 1 {
|
if affected != 1 {
|
||||||
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
|
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,32 +196,31 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rows, err := cache.db.QueryContext(t.Context(),
|
rows, err := cache.db.QueryContext(t.Context(),
|
||||||
`SELECT content_hash FROM source_metadata
|
`SELECT source_key FROM source_content`,
|
||||||
WHERE content_hash IS NOT NULL AND content_hash != ''`,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query source_metadata: %v", err)
|
t.Fatalf("failed to query source_content: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var hash string
|
var key string
|
||||||
|
|
||||||
err := rows.Scan(&hash)
|
err := rows.Scan(&key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to scan content_hash: %v", err)
|
t.Fatalf("failed to scan source_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(ContentHash(hash)) {
|
if !cache.srcContent.Exists(imagerequest.SourceKey(key)) {
|
||||||
t.Errorf("source_metadata references content %s but the file is missing",
|
t.Errorf("source_content references content %s but the file is missing",
|
||||||
hash)
|
key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("source_metadata iteration failed: %v", err)
|
t.Fatalf("source_content iteration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variantRows, err := cache.db.QueryContext(t.Context(),
|
variantRows, err := cache.db.QueryContext(t.Context(),
|
||||||
@@ -245,7 +239,7 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
|||||||
t.Fatalf("failed to scan cache_key: %v", err)
|
t.Fatalf("failed to scan cache_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.variants.Exists(VariantKey(key)) {
|
if !cache.variants.Exists(imagerequest.VariantKey(key)) {
|
||||||
t.Errorf("variant_content references key %s but the file is missing", key)
|
t.Errorf("variant_content references key %s but the file is missing", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,33 +303,6 @@ func TestUsageBytesAccountsSourceAndVariantBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUsageBytesCountsMultiReferencedBlobOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xCC}, 1200)
|
|
||||||
|
|
||||||
hashOne := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/one.jpg", content)
|
|
||||||
hashTwo := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/two.jpg", content)
|
|
||||||
|
|
||||||
if hashOne != hashTwo {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s",
|
|
||||||
hashOne, hashTwo)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage != 1200 {
|
|
||||||
t.Errorf("UsageBytes = %d, want 1200 (deduplicated blob counted once)", usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -344,7 +311,7 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
|||||||
cache, _ := newEvictionTestCache(t, limit)
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
keys := []VariantKey{
|
keys := []imagerequest.VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo,
|
testVariantKeyOne, testVariantKeyTwo,
|
||||||
testVariantKeyThree, testVariantKeyFour,
|
testVariantKeyThree, testVariantKeyFour,
|
||||||
}
|
}
|
||||||
@@ -389,102 +356,15 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 1000
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// One 800-byte blob referenced by two source paths.
|
|
||||||
sharedContent := bytes.Repeat([]byte{0xDD}, 800)
|
|
||||||
sharedHash := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/a.jpg", sharedContent)
|
|
||||||
|
|
||||||
h := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/b.jpg", sharedContent)
|
|
||||||
if h != sharedHash {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s",
|
|
||||||
h, sharedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A newer 600-byte blob referenced by one source path.
|
|
||||||
recentHash := storeEvictionTestSource(t, cache, "src.example.com", "/c.jpg",
|
|
||||||
bytes.Repeat([]byte{0xEE}, 600))
|
|
||||||
|
|
||||||
setSourceLastAccessed(t, cache, sharedHash, now.Add(-2*time.Hour))
|
|
||||||
setSourceLastAccessed(t, cache, recentHash, now.Add(-time.Minute))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage > limit {
|
|
||||||
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The multi-referenced blob must be gone from disk, from
|
|
||||||
// source_content, and from BOTH source_metadata rows: references
|
|
||||||
// are removed together with the blob, never left dangling.
|
|
||||||
if cache.srcContent.Exists(sharedHash) {
|
|
||||||
t.Errorf("evicted blob %s still exists on disk", sharedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(sharedHash),
|
|
||||||
); n != 0 {
|
|
||||||
t.Errorf("evicted blob %s still has %d source_content rows", sharedHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(sharedHash),
|
|
||||||
); n != 0 {
|
|
||||||
t.Errorf("evicted blob %s still has %d source_metadata references", sharedHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The JSON metadata sidecars for both referencing paths must be
|
|
||||||
// removed along with the rows.
|
|
||||||
for _, path := range []string{"/a.jpg", "/b.jpg"} {
|
|
||||||
pathHash := HashPath(path + "?")
|
|
||||||
if cache.srcMetadata.Exists("src.example.com", pathHash) {
|
|
||||||
t.Errorf("metadata sidecar for %s must be removed with its row", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The more recently used blob survives fully intact.
|
|
||||||
if !cache.srcContent.Exists(recentHash) {
|
|
||||||
t.Errorf("recently used blob %s must survive eviction", recentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(recentHash),
|
|
||||||
); n != 1 {
|
|
||||||
t.Errorf("recently used blob %s has %d source_metadata rows, want 1",
|
|
||||||
recentHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xDF}, 800)
|
content := bytes.Repeat([]byte{0xDF}, 800)
|
||||||
hash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content)
|
keys := []imagerequest.SourceKey{
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content),
|
||||||
h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content)
|
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content),
|
||||||
if h != hash {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s", h, hash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
@@ -495,14 +375,16 @@ func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
|||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(hash) {
|
for _, key := range keys {
|
||||||
t.Errorf("blob %s must not be evicted while usage is under the limit", hash)
|
if !cache.srcContent.Exists(key) {
|
||||||
}
|
t.Errorf("source %s must not be evicted while usage is under the limit", key)
|
||||||
|
}
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
if n := countRows(t, cache,
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(hash),
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
||||||
); n != 2 {
|
); n != 1 {
|
||||||
t.Errorf("blob %s has %d source_metadata rows, want 2", hash, n)
|
t.Errorf("source %s has %d source_content rows, want 1", key, n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.variants.Exists(testVariantKeyOne) {
|
if !cache.variants.Exists(testVariantKeyOne) {
|
||||||
@@ -517,12 +399,16 @@ func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
|||||||
|
|
||||||
cache, tmpDir := newEvictionTestCache(t, 0)
|
cache, tmpDir := newEvictionTestCache(t, 0)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &imagerequest.Request{
|
||||||
SourceHost: "src.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/a.jpg",
|
Host: "src.example.com",
|
||||||
Format: FormatJPEG,
|
Path: "/a.jpg",
|
||||||
Quality: 85,
|
},
|
||||||
FitMode: FitCover,
|
Transform: imagerequest.Transform{
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
assertDisabledCacheWritesAreNoOps(t, cache, req)
|
assertDisabledCacheWritesAreNoOps(t, cache, req)
|
||||||
@@ -534,62 +420,45 @@ func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
|||||||
// assertDisabledCacheWritesAreNoOps verifies that stores against a
|
// assertDisabledCacheWritesAreNoOps verifies that stores against a
|
||||||
// disabled cache report success without recording anything.
|
// disabled cache report success without recording anything.
|
||||||
func assertDisabledCacheWritesAreNoOps(
|
func assertDisabledCacheWritesAreNoOps(
|
||||||
t *testing.T, cache *Cache, req *ImageRequest,
|
t *testing.T, cache *Cache, req *imagerequest.Request,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
err := cache.StoreVariant(
|
||||||
ctx, CacheKey(req), bytes.NewReader([]byte("data")), "image/webp",
|
ctx, imagerequest.CacheKey(req), bytes.NewReader([]byte("data")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &httpfetcher.FetchResult{
|
key, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")))
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: 4,
|
|
||||||
Headers: map[string][]string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash != "" {
|
if key != "" {
|
||||||
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
|
t.Errorf("StoreSource on disabled cache returned key %q, want empty", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertDisabledCacheReadsAlwaysMiss verifies that lookups against a
|
// assertDisabledCacheReadsAlwaysMiss verifies that lookups against a
|
||||||
// disabled cache never report a hit.
|
// disabled cache never report a hit.
|
||||||
func assertDisabledCacheReadsAlwaysMiss(
|
func assertDisabledCacheReadsAlwaysMiss(
|
||||||
t *testing.T, cache *Cache, req *ImageRequest,
|
t *testing.T, cache *Cache, req *imagerequest.Request,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
lookup, err := cache.Lookup(ctx, req)
|
key, err := cache.LookupSource(ctx, req)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup on disabled cache failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lookup.Hit {
|
|
||||||
t.Error("Lookup on disabled cache must always miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
srcHash, srcType, err := cache.LookupSource(ctx, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcHash != "" || srcType != "" {
|
if key != "" {
|
||||||
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
t.Errorf("LookupSource on disabled cache = %q, want empty", key)
|
||||||
srcHash, srcType)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,10 +479,6 @@ func assertDisabledCacheTracksNothing(t *testing.T, cache *Cache) {
|
|||||||
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
||||||
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_metadata`); n != 0 {
|
|
||||||
t.Errorf("disabled cache wrote %d source_metadata rows, want 0", n)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
||||||
@@ -662,7 +527,7 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
|||||||
cache.StartEviction(time.Hour)
|
cache.StartEviction(time.Hour)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []imagerequest.VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
fills := []byte{0x11, 0x12, 0x13}
|
fills := []byte{0x11, 0x12, 0x13}
|
||||||
@@ -716,7 +581,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []imagerequest.VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
fills := []byte{0x21, 0x22, 0x23}
|
fills := []byte{0x21, 0x22, 0x23}
|
||||||
@@ -724,7 +589,7 @@ 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))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store variant file: %v", err)
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -776,7 +641,7 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
|||||||
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
||||||
|
|
||||||
_, err := cache.variants.Store(
|
_, err := cache.variants.Store(
|
||||||
testVariantKeyOne, bytes.NewReader(untracked), "image/webp",
|
testVariantKeyOne, bytes.NewReader(untracked),
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -784,9 +649,8 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
|||||||
|
|
||||||
// An accounting row whose file is missing must be dropped.
|
// An accounting row whose file is missing must be dropped.
|
||||||
_, err = cache.db.ExecContext(t.Context(),
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
`INSERT INTO variant_content (cache_key, size_bytes) VALUES (?, ?)`,
|
||||||
VALUES (?, ?, ?)`,
|
"deadbeef0001", 700,
|
||||||
"deadbeef0001", 700, "image/webp",
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to insert stale variant accounting row: %v", err)
|
t.Fatalf("failed to insert stale variant accounting row: %v", err)
|
||||||
@@ -878,7 +742,7 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
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),
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -919,6 +783,89 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReconciliationPassesAreSpacedByTenTimesTheirTime makes the hook
|
||||||
|
// called after every read of the reconciliation pass sleep 10 ms, runs
|
||||||
|
// the eviction loop on a 10 ms interval, and records when each of those
|
||||||
|
// calls starts and ends. A pass starts before its first call and ends
|
||||||
|
// after its last, so the time from the first call's start to the last
|
||||||
|
// call's end is at most the pass's time. The next pass's first call must
|
||||||
|
// start at least ten times that long after the last call ended.
|
||||||
|
func TestReconciliationPassesAreSpacedByTenTimesTheirTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
// A pass made here, before the loop starts, counts the reads one
|
||||||
|
// pass makes on this empty cache, so the reads recorded below can be
|
||||||
|
// split into passes.
|
||||||
|
readsPerPass := 0
|
||||||
|
cache.reconciliationReadTestHook = func(int) { readsPerPass++ }
|
||||||
|
|
||||||
|
err := cache.reconcileAccounting(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("one reconciliation pass makes %d reads", readsPerPass)
|
||||||
|
|
||||||
|
if readsPerPass == 0 {
|
||||||
|
t.Fatal("a reconciliation pass made no read")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pass at startup and two after it.
|
||||||
|
const passes = 3
|
||||||
|
|
||||||
|
type read struct{ start, end time.Time }
|
||||||
|
|
||||||
|
// The channel holds every read the test looks at, so none of those
|
||||||
|
// is dropped; reads after them are, and never block the loop.
|
||||||
|
reads := make(chan read, passes*readsPerPass)
|
||||||
|
|
||||||
|
cache.reconciliationReadTestHook = func(int) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case reads <- read{start: start, end: time.Now()}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.StartEviction(10 * time.Millisecond)
|
||||||
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
|
recorded := make([]read, 0, passes*readsPerPass)
|
||||||
|
timeout := time.After(15 * time.Second)
|
||||||
|
|
||||||
|
for len(recorded) < passes*readsPerPass {
|
||||||
|
select {
|
||||||
|
case r := <-reads:
|
||||||
|
recorded = append(recorded, r)
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatalf("saw %d reconciliation reads in 15 s, want %d (%d passes)",
|
||||||
|
len(recorded), passes*readsPerPass, passes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pass := range passes - 1 {
|
||||||
|
first := recorded[pass*readsPerPass]
|
||||||
|
last := recorded[(pass+1)*readsPerPass-1]
|
||||||
|
next := recorded[(pass+1)*readsPerPass]
|
||||||
|
|
||||||
|
passTime := last.end.Sub(first.start)
|
||||||
|
wait := next.start.Sub(last.end)
|
||||||
|
|
||||||
|
t.Logf("pass %d took at least %v, and the next pass started %v after it",
|
||||||
|
pass+1, passTime, wait)
|
||||||
|
|
||||||
|
if wait < 10*passTime {
|
||||||
|
t.Errorf("pass %d took at least %v, and the next pass started %v "+
|
||||||
|
"after it, want at least %v", pass+1, passTime, wait, 10*passTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStopEvictionInterruptsPassInProgress holds the test database's
|
// TestStopEvictionInterruptsPassInProgress holds the test database's
|
||||||
// only connection, so the startup reconciliation pass waits for it, and
|
// only connection, so the startup reconciliation pass waits for it, and
|
||||||
// checks that StopEviction stops that pass instead of waiting for the
|
// checks that StopEviction stops that pass instead of waiting for the
|
||||||
@@ -973,9 +920,15 @@ func TestStopEvictionInterruptsPassInProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
||||||
// while the oldest of three source blobs is being evicted, and checks that
|
// once the oldest of three sources is evicted, while the next one waits
|
||||||
// EvictToLimit then returns context.Canceled without evicting the other
|
// for the database, and checks that EvictToLimit then returns
|
||||||
// two or logging a warning for either of them.
|
// context.Canceled, with that source gone and the other two kept, and
|
||||||
|
// logs no warning. The test database has one connection. When the
|
||||||
|
// deletion of the oldest source's row commits, a commit hook holds the
|
||||||
|
// deletion until the test waits for the connection, so the test takes it
|
||||||
|
// as soon as the deletion is done. The eviction of the next source then
|
||||||
|
// waits for the connection, and the test cancels the context while it
|
||||||
|
// waits.
|
||||||
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -985,7 +938,7 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|||||||
|
|
||||||
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||||
|
|
||||||
hashes := []ContentHash{
|
keys := []imagerequest.SourceKey{
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
||||||
bytes.Repeat([]byte{0x61}, 1000)),
|
bytes.Repeat([]byte{0x61}, 1000)),
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
||||||
@@ -996,69 +949,155 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|||||||
|
|
||||||
base := time.Now().Add(-time.Hour)
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
for i, hash := range hashes {
|
for i, key := range keys {
|
||||||
setSourceLastAccessed(t, cache, hash, base.Add(time.Duration(i)*time.Minute))
|
setSourceLastAccessed(t, cache, key, base.Add(time.Duration(i)*time.Minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
oldestRowDeleted := make(chan struct{})
|
||||||
|
|
||||||
|
var oldestRowDeletedOnce sync.Once
|
||||||
|
|
||||||
|
setCommitHook(t, cache, func() int32 {
|
||||||
|
oldestRowDeletedOnce.Do(func() {
|
||||||
|
close(oldestRowDeleted)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for cache.db.Stats().WaitCount == waitsBefore && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(ContentHash) { cancel() }
|
evicted := make(chan error, 1)
|
||||||
|
|
||||||
err := cache.EvictToLimit(ctx)
|
go func() { evicted <- cache.EvictToLimit(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldestRowDeleted:
|
||||||
|
case err := <-evicted:
|
||||||
|
t.Fatalf("EvictToLimit() returned %v without deleting a row", err)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("EvictToLimit never deleted a row")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+2)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := <-evicted
|
||||||
t.Logf("EvictToLimit() error = %v", err)
|
t.Logf("EvictToLimit() error = %v", err)
|
||||||
t.Logf("log output: %s", logBuf.String())
|
t.Logf("log output: %s", logBuf.String())
|
||||||
|
|
||||||
|
// The checks below read the database, so the connection goes back.
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
setCommitHook(t, cache, nil)
|
||||||
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.srcContent.Exists(hashes[0]) {
|
assertOnlyOldestSourceEvicted(t, cache, keys)
|
||||||
t.Errorf("source blob %s, evicted when the context was cancelled, "+
|
|
||||||
"is still on disk", hashes[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range hashes[1:] {
|
|
||||||
if !cache.srcContent.Exists(hash) {
|
|
||||||
t.Errorf("source blob %s was evicted after the context was cancelled", hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
||||||
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCommitHook makes the SQLite driver call hook each time a write on
|
||||||
|
// the test database's one connection commits, or stops it when hook is
|
||||||
|
// nil.
|
||||||
|
func setCommitHook(t *testing.T, cache *Cache, hook sqlite.CommitHookFn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
err = conn.Raw(func(driverConn any) error {
|
||||||
|
hooks, ok := driverConn.(sqlite.HookRegisterer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("the database connection is a %T, which takes no hooks",
|
||||||
|
driverConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.RegisterCommitHook(hook)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set the commit hook: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertOnlyOldestSourceEvicted checks that the first of keys has neither
|
||||||
|
// its file nor its row, and that every other one has both.
|
||||||
|
func assertOnlyOldestSourceEvicted(
|
||||||
|
t *testing.T, cache *Cache, keys []imagerequest.SourceKey,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cache.srcContent.Exists(keys[0]) {
|
||||||
|
t.Errorf("source %s, evicted when the context was cancelled, "+
|
||||||
|
"is still on disk", keys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys[1:] {
|
||||||
|
if !cache.srcContent.Exists(key) {
|
||||||
|
t.Errorf("source %s was evicted after the context was cancelled", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("source %s, kept, has %d source_content rows, want 1", key, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopEvictionReturnsWhenItsContextEnds pauses an eviction pass where
|
// TestStopEvictionReturnsWhenItsContextEnds pauses the startup
|
||||||
// cancellation cannot reach it, after a source blob's rows are deleted and
|
// reconciliation pass in the hook called after each of its reads, where
|
||||||
// before its file is removed, and checks that StopEviction returns its
|
// cancellation cannot reach it, and checks that StopEviction returns an
|
||||||
// context's error when that context ends instead of waiting for the pass.
|
// error wrapping its context's error when that context ends, instead of
|
||||||
// Once the pass goes on, the goroutine exits and no row points at a
|
// waiting for the pass. Once the pass goes on, the goroutine exits, and a
|
||||||
// missing file.
|
// second StopEviction returns nil.
|
||||||
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
paused := make(chan struct{})
|
paused := make(chan struct{})
|
||||||
resume := make(chan struct{})
|
resume := make(chan struct{})
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(ContentHash) {
|
var pauseOnce sync.Once
|
||||||
close(paused)
|
|
||||||
<-resume
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := storeEvictionTestSource(t, cache, "stop.example.com", "/a.jpg",
|
cache.reconciliationReadTestHook = func(int) {
|
||||||
bytes.Repeat([]byte{0x61}, 1000))
|
pauseOnce.Do(func() {
|
||||||
|
close(paused)
|
||||||
|
<-resume
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
cache.StartEviction(time.Hour)
|
cache.StartEviction(time.Hour)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-paused:
|
case <-paused:
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("the eviction pass never reached the source blob")
|
t.Fatal("the reconciliation pass never made a read")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||||
@@ -1077,12 +1116,6 @@ func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
|
|
||||||
if cache.srcContent.Exists(hash) {
|
|
||||||
t.Errorf("source blob %s is still on disk after its rows were deleted", hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
||||||
@@ -1154,125 +1187,3 @@ func TestReconciliationPassLogsNoWarningOnceCancelled(t *testing.T) {
|
|||||||
t.Errorf("runReconciliationPass logged a warning with a cancelled context")
|
t.Errorf("runReconciliationPass logged a warning with a cancelled context")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent exercises
|
|
||||||
// the exact TOCTOU window between evictSourceBlob's row-deletion
|
|
||||||
// transaction commit and its content file unlink: a concurrent
|
|
||||||
// StoreSource for a different source path whose content hashes to the
|
|
||||||
// same value (real SHA-256 dedup, not a contrived case) must not be
|
|
||||||
// able to insert a fresh row referencing the file while eviction is
|
|
||||||
// mid-unlink, and must not lose its own store once eviction has fully
|
|
||||||
// released the content hash.
|
|
||||||
func TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0x55}, 400)
|
|
||||||
|
|
||||||
hash := storeEvictionTestSource(t, cache,
|
|
||||||
"race.example.com", "/first.jpg", content)
|
|
||||||
|
|
||||||
proceed := make(chan struct{})
|
|
||||||
storeAttempted := make(chan struct{})
|
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(gotHash ContentHash) {
|
|
||||||
if gotHash != hash {
|
|
||||||
t.Errorf("test hook invoked for hash %s, want %s", gotHash, hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(storeAttempted)
|
|
||||||
<-proceed
|
|
||||||
}
|
|
||||||
|
|
||||||
evictDone := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
evictDone <- cache.evictSourceBlob(ctx, hash)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait until eviction has committed its delete transaction and is
|
|
||||||
// paused (inside the test hook) immediately before unlinking the
|
|
||||||
// content file: exactly the window the review flagged.
|
|
||||||
<-storeAttempted
|
|
||||||
|
|
||||||
storeDone := storeIdenticalContentConcurrently(ctx, cache, content)
|
|
||||||
|
|
||||||
// The concurrent store must not be able to complete while eviction
|
|
||||||
// still holds the content hash (i.e. before the file is unlinked):
|
|
||||||
// if it could, it would insert a row referencing a file about to be
|
|
||||||
// removed out from under it.
|
|
||||||
select {
|
|
||||||
case err := <-storeDone:
|
|
||||||
t.Fatalf("StoreSource for identical content completed (err=%v) while eviction "+
|
|
||||||
"still held the content hash open between commit and unlink; the store and "+
|
|
||||||
"the evict of identical content are not mutually exclusive", err)
|
|
||||||
case <-time.After(200 * time.Millisecond):
|
|
||||||
// Expected: the store is blocked behind eviction's exclusion.
|
|
||||||
}
|
|
||||||
|
|
||||||
close(proceed)
|
|
||||||
|
|
||||||
err := <-evictDone
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evictSourceBlob failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = <-storeDone
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreSource failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
|
|
||||||
dupHash, _, err := cache.LookupSource(ctx, &ImageRequest{
|
|
||||||
SourceHost: "race.example.com",
|
|
||||||
SourcePath: "/dup.jpg",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LookupSource failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dupHash == "" {
|
|
||||||
t.Fatal("re-stored blob was lost: the store legitimately ran after eviction " +
|
|
||||||
"released the content hash and must have recreated the file and row")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cache.srcContent.Exists(dupHash) {
|
|
||||||
t.Errorf("source_content/source_metadata references %s but its file is missing",
|
|
||||||
dupHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeIdenticalContentConcurrently starts a StoreSource for a second
|
|
||||||
// source path whose body hashes to the same content hash, returning the
|
|
||||||
// channel its error is delivered on.
|
|
||||||
func storeIdenticalContentConcurrently(
|
|
||||||
ctx context.Context, cache *Cache, content []byte,
|
|
||||||
) chan error {
|
|
||||||
storeDone := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "race.example.com",
|
|
||||||
SourcePath: "/dup.jpg",
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
result := &httpfetcher.FetchResult{
|
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: int64(len(content)),
|
|
||||||
Headers: map[string][]string{
|
|
||||||
testHeaderContentType: {testContentTypeJPEG},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := cache.StoreSource(ctx, req, bytes.NewReader(content), result)
|
|
||||||
storeDone <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
return storeDone
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user