Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47a2c2360a | ||
|
|
84a86274cd | ||
|
|
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 | ||
|
|
23ec4026f6 | ||
|
|
ef828f71a5 | ||
|
|
f3231a3c5a | ||
|
|
cca2e3f926 | ||
|
|
708a9bec20 | ||
|
|
8314099abd | ||
|
|
8568c17d1b | ||
|
|
625fd42ace | ||
|
|
66e71b4207 | ||
|
|
847ad5b428 | ||
|
|
233a9c05ad | ||
|
|
842372250f | ||
|
|
58d601ea48 | ||
|
|
48f21d4ecf | ||
|
|
f8c437b83f | ||
|
|
04093f53ad | ||
|
|
be6c715b36 | ||
|
|
604b51eea6 | ||
|
|
ba5a716223 |
+77
-7
@@ -1,12 +1,82 @@
|
|||||||
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
|
# into .gitignore, where `**/` is wrong.
|
||||||
|
#
|
||||||
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
|
#
|
||||||
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
# .git is sent without its config. Without a VERSION build argument the
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
# password in a remote URL or the token the CI checkout step stores there.
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
.git/config
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
.gitignore
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
.DS_Store
|
# its own .git directory when it keeps one.
|
||||||
.env*
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
|
# `**/.git/modules/**/config` also matches that segment's directory
|
||||||
|
# under .git/modules/. Go's version stamping then fails the build;
|
||||||
|
# nothing leaks. Name such a submodule without that segment:
|
||||||
|
# `git submodule add --name`.
|
||||||
|
**/.git/config
|
||||||
|
**/.git/modules/**/config
|
||||||
|
|
||||||
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
|
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||||
|
# `services/api/.claude/` and must add its own anchored entry.
|
||||||
.claude
|
.claude
|
||||||
node_modules
|
|
||||||
bin/
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
data/
|
# convention. Re-include a committed template with a negation if the
|
||||||
|
# build needs one: `!docs/example.env`.
|
||||||
|
**/*.[eE][nN][vV]
|
||||||
|
**/.[eE][nN][vV].*
|
||||||
|
**/.[eE][nN][vV][rR][cC]
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them. Public certificates
|
||||||
|
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||||
|
**/*.[pP][eE][mM]
|
||||||
|
**/*.[kK][eE][yY]
|
||||||
|
**/*.[pP]12
|
||||||
|
**/*.[pP][fF][xX]
|
||||||
|
**/[iI][dD]_[rR][sS][aA]
|
||||||
|
**/[iI][dD]_[dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
**/[iI][dD]_[eE][dD]25519
|
||||||
|
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Dependencies: restored inside the image, never copied in.
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# OS metadata.
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Editor state: never a build input, and it churns COPY.
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/*.bak
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.sublime-*
|
||||||
|
|
||||||
|
# pixa's own entries. On the host, `make build` writes bin/pixad, and the
|
||||||
|
# example config keeps its state directory in data/.
|
||||||
|
/bin
|
||||||
|
/data
|
||||||
|
|
||||||
|
# Local config files, kept out of git because they can hold the signing key.
|
||||||
|
**/[cC][oO][nN][fF][iI][gG].[yY][mM][lL]
|
||||||
|
**/[cC][oO][nN][fF][iI][gG].[yY][aA][mM][lL]
|
||||||
|
**/[cC][oO][nN][fF][iI][gG].[dD][eE][vV].[yY][mM][lL]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
||||||
|
# both sides instead of conflicting. Git never reports a conflict here: read
|
||||||
|
# the merged entries after every merge or rebase.
|
||||||
|
TODO.md merge=union
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
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
|
- run: script/docker-smoke
|
||||||
|
|||||||
+44
-6
@@ -11,15 +11,52 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
|
|
||||||
# Environment / secrets
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
.env
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
.env.*
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
*.pem
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
*.key
|
.claude/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Secrets. Unanchored like every entry above, so each matches at every
|
||||||
|
# depth. Matching is case-sensitive on Linux, so names use character
|
||||||
|
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Only the templates `example.env` and `sample.env` are
|
||||||
|
# re-included below. A repository that commits any other template adds
|
||||||
|
# its own negation at the end of this file, for example `!.env.example`.
|
||||||
|
*.[eE][nN][vV]
|
||||||
|
.[eE][nN][vV].*
|
||||||
|
.[eE][nN][vV][rR][cC]
|
||||||
|
!example.env
|
||||||
|
!sample.env
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them.
|
||||||
|
*.[pP][eE][mM]
|
||||||
|
*.[kK][eE][yY]
|
||||||
|
*.[pP]12
|
||||||
|
*.[pP][fF][xX]
|
||||||
|
[iI][dD]_[rR][sS][aA]
|
||||||
|
[iI][dD]_[dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
[iI][dD]_[eE][dD]25519
|
||||||
|
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# This repository's own entries, such as its build outputs, go below
|
||||||
|
# this comment, and a re-vendor keeps them. Anchor a binary built at the
|
||||||
|
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
vendor/
|
vendor/
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/bin/
|
/bin/
|
||||||
@@ -31,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
|
||||||
|
|||||||
+69
-2
@@ -10,14 +10,23 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- 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
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
|
# Misses findings at random in v2.14.0; back once a pinned release fixes it
|
||||||
|
- canonicalheader
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -28,6 +37,64 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# test-support package exists to hand a test privileges the program
|
||||||
|
# itself must never have, so a file that is not a test must not import
|
||||||
|
# one. Test files, and the files inside a package whose directory name
|
||||||
|
# ends in `test`, are where that code belongs, and are exempt.
|
||||||
|
#
|
||||||
|
# The deny list below is the one part of this file a repository is
|
||||||
|
# expected to extend, and the only part it may. depguard matches an
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
|
deny:
|
||||||
|
- pkg: net/http/httptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
# Only decisions already recorded in the Go package defaults are
|
||||||
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
|
blocked:
|
||||||
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
|
- log/slog
|
||||||
|
reason: "Structured logging is stdlib log/slog."
|
||||||
|
# One entry per pre-fork module path, because the later releases
|
||||||
|
# are separate paths. A prefix match would be shorter but would
|
||||||
|
# also reach github.com/go-redis/redismock, the test double for
|
||||||
|
# the successor these entries recommend.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/sergi/go-diff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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.
|
||||||
@@ -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.
|
|
||||||
-1259
File diff suppressed because it is too large
Load Diff
+83
-49
@@ -1,80 +1,114 @@
|
|||||||
# Lint stage
|
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||||
# Same image as Dockerfile.lint: change both pins together.
|
# on every build, which reruns every RUN step after it. The steps that
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
# compile mount Go's build cache, which survives from build to build.
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
|
||||||
|
# Lint phase. script/lint builds it alone. The linter is run directly:
|
||||||
|
# `make lint` and script/lint are themselves a docker build.
|
||||||
|
# golangci/golangci-lint:v2.14.0, 2026-10-15
|
||||||
|
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
|
# The linter compiles every package, and govips needs the libvips
|
||||||
|
# headers for that. REPO_POLICIES.md has the lint phase install them
|
||||||
|
# itself; this image is Debian, so with apt-get rather than apk.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase. script/test builds it alone. -race needs cgo and so a C
|
||||||
|
# compiler, which the Debian Go image ships and the alpine one does not.
|
||||||
|
# golang:1.25.4-trixie, 2026-10-15
|
||||||
|
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# script/bootstrap installs the build dependencies and downloads the Go
|
# script/bootstrap --cgo installs the build dependencies (the libvips
|
||||||
# modules. Only script/, go.mod and go.sum are copied first, so this
|
# and libheif headers, and libvips' JPEG XL support, which the tests
|
||||||
# layer is reused until one of them changes.
|
# need) and downloads the Go modules.
|
||||||
COPY script/ ./script/
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap --cgo
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
# Without -v first; on a failure, again with -v for the details, and
|
||||||
ENV container=docker
|
# the step fails even if the second run passes. -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 this stage runs only when lint and
|
||||||
# command, so a new value reruns the step instead of reusing a cached
|
# test passed.
|
||||||
# success that checked nothing. A plain `docker build .` leaves it empty
|
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
||||||
# and reuses the check steps only for an identical build context.
|
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||||
ARG CHECK_EPOCH
|
ARG CACHEBUST
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
|
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make lint
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
# golang:1.25.4-alpine, 2026-02-25
|
|
||||||
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
|
||||||
|
|
||||||
# Depend on lint stage passing
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Build dependencies and Go modules, as in the lint stage
|
# Build dependencies, git among them, and Go modules, as in the test
|
||||||
|
# phase
|
||||||
COPY script/ ./script/
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap --cgo
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
|
||||||
# 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
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
# Install runtime dependencies only
|
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
||||||
|
# support, without which pixad does not start.
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
vips \
|
vips \
|
||||||
|
vips-jxl \
|
||||||
libheif \
|
libheif \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata \
|
tzdata \
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
|
|
||||||
# which is never installed on the host. Pinned to the same image as the
|
|
||||||
# Dockerfile lint stage: change both pins together, or the two run
|
|
||||||
# different linter versions.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
|
||||||
# this image needs the same C libraries the build does. script/bootstrap
|
|
||||||
# installs them and downloads the Go modules. Only script/, go.mod and
|
|
||||||
# go.sum are copied first; they settle this layer's result, so it may
|
|
||||||
# safely be reused between runs.
|
|
||||||
COPY script/ ./script/
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Tells script/lint it is inside a container, so it runs the linter.
|
|
||||||
ENV container=docker
|
|
||||||
|
|
||||||
# script/lint passes a different CACHEBUST on every run, and BuildKit
|
|
||||||
# keys every RUN after this ARG on its value, so the lint step always
|
|
||||||
# runs instead of returning a cached success that linted nothing.
|
|
||||||
#
|
|
||||||
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
|
|
||||||
# tmpfs that is discarded after the step. Written into the layer, they
|
|
||||||
# would pile up as build cache on every run, since no later run, with
|
|
||||||
# its new CACHEBUST, can reuse that layer.
|
|
||||||
ARG CACHEBUST
|
|
||||||
RUN --mount=type=tmpfs,target=/root/.cache script/lint
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
|
|
||||||
# Use nix-shell to provide CGO dependencies unless they are already available
|
# Use nix-shell to provide CGO dependencies unless they are already available
|
||||||
# (e.g. inside a Docker build or an existing nix-shell).
|
# (e.g. inside an existing nix-shell).
|
||||||
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
|
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
|
||||||
ifdef HAS_PKGCONFIG
|
ifdef HAS_PKGCONFIG
|
||||||
NIX_RUN_PREFIX =
|
NIX_RUN_PREFIX =
|
||||||
@@ -32,11 +32,11 @@ fmt-check:
|
|||||||
fmt:
|
fmt:
|
||||||
@script/fmt
|
@script/fmt
|
||||||
|
|
||||||
# Run linter
|
# Run linter (the lint phase of the Dockerfile)
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
# Run tests (30-second timeout)
|
# Run tests (the test phase of the Dockerfile)
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
@@ -59,20 +59,25 @@ docker:
|
|||||||
docker-smoke:
|
docker-smoke:
|
||||||
@script/docker-smoke
|
@script/docker-smoke
|
||||||
|
|
||||||
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
|
# Measure throughput, latency and peak memory with the default duration and
|
||||||
docker-versioned:
|
# number of clients (needs Docker and Go; a benchmark, not part of check)
|
||||||
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
loadtest:
|
||||||
|
@script/loadtest
|
||||||
|
|
||||||
# Run tests in Docker (needed for CGO/libvips)
|
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
||||||
|
docker-versioned:
|
||||||
|
@script/docker
|
||||||
|
docker tag pixa pixa:$(VERSION)
|
||||||
|
|
||||||
|
# Run tests in Docker, as `make test` does
|
||||||
docker-test:
|
docker-test:
|
||||||
docker build --target builder --build-arg VERSION=$(VERSION) -t pixad-builder .
|
@script/test
|
||||||
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
|
|
||||||
|
|
||||||
# Run local dev server in Docker
|
# Run local dev server in Docker
|
||||||
devserver: docker-versioned devserver-stop
|
devserver: docker-versioned devserver-stop
|
||||||
docker run -d --name pixad-dev -p 8080:8080 \
|
docker run -d --name pixad-dev -p 8080:8080 \
|
||||||
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
||||||
pixad:latest
|
pixa:latest
|
||||||
@echo "pixad running at http://localhost:8080"
|
@echo "pixad running at http://localhost:8080"
|
||||||
|
|
||||||
# Stop dev server
|
# Stop dev server
|
||||||
|
|||||||
+436
-88
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-07-06
|
last_modified: 2026-10-15
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -54,21 +54,37 @@ 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 and runs `docker build .`; the Gitea workflow calls it. Four further
|
because the `Dockerfile` stages start from a node image whose exact version is
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
outputs the project's name. Scripts that need the name call
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
|
containerised gates have passed. **The bootstrap alone is not enough**: when
|
||||||
|
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
|
||||||
|
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
|
||||||
|
under another node. The host entrypoints that need yarn — `script/fmt` and
|
||||||
|
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
|
||||||
|
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
|
||||||
|
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
|
||||||
|
pinned node. A runner carrying nothing but docker and git then gets through
|
||||||
|
`script/check`. Four further scripts are our own extensions to the standard:
|
||||||
|
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
|
||||||
|
`script/precommit` is what the git pre-commit hook runs, and it calls
|
||||||
|
`script/check`; `script/install-precommit` installs the git pre-commit hook
|
||||||
|
(the `make hooks` target shims to it); and `script/projectname` (literally
|
||||||
|
that filename) simply outputs the project's name. Scripts that need the name
|
||||||
|
call `script/projectname` — e.g. `script/docker` assembles its image tag from
|
||||||
|
it — so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
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
|
`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
|
||||||
@@ -89,87 +105,257 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
The gate phases and the build stage start from their pinned base images and
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
layer stays cached until dependencies change.
|
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.
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
and nothing else:
|
||||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
|
||||||
then declares an explicit dependency on the lint stage via
|
|
||||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
|
||||||
linting before proceeding to compilation and tests. This ensures lint failures
|
|
||||||
surface in seconds rather than minutes, without blocking on dependency
|
|
||||||
download or compilation in the build stage.
|
|
||||||
|
|
||||||
The standard pattern for a Go repo Dockerfile is:
|
```sh
|
||||||
|
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||||
|
--target lint --output type=cacheonly .
|
||||||
|
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||||
|
--target test --output type=cacheonly .
|
||||||
|
```
|
||||||
|
|
||||||
|
**A stage that is not the last one in the file is built only when the final
|
||||||
|
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||||
|
two gates are always invoked by name here, and why the final stage carries a
|
||||||
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||||
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
|
and tested nothing.
|
||||||
|
|
||||||
|
**The gate builds write no image.** With `--output type=cacheonly` the phase
|
||||||
|
runs and a failing step fails the build, but the result is not exported.
|
||||||
|
Nothing uses those images, and writing one out is slow: a Go test phase's
|
||||||
|
image holds the toolchain. A build given neither `--output` nor `-t` writes
|
||||||
|
an untagged image and leaves it dangling, on every developer host and every
|
||||||
|
CI runner. `script/cibuild` and `script/docker` build the image that ships
|
||||||
|
and tag it, so each build replaces the previous image; each assigns the tag
|
||||||
|
on its own line before the build, so `set -e` stops it where
|
||||||
|
`script/projectname` fails.
|
||||||
|
|
||||||
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
|
themselves a `docker build` and would recurse into a daemon that does not
|
||||||
|
exist in a build step. Formatting is the exception and stays on the host:
|
||||||
|
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||||
|
read-only twin.
|
||||||
|
|
||||||
|
**No lint verdict may come from a host invocation of the linter.** On a
|
||||||
|
shared host golangci-lint reads a result cache keyed on file content rather
|
||||||
|
than location, so a second checkout of the same content is served the first
|
||||||
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
|
org, in both directions. A container has its own golangci-lint cache, its
|
||||||
|
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
||||||
|
invalidates a `COPY` layer only when the copied content changes, so on an
|
||||||
|
unchanged tree the check `RUN` is served from cache, nothing executes, and the
|
||||||
|
build still exits 0. Every `docker build` in `script/` therefore passes
|
||||||
|
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
|
||||||
|
id, so no two builds share a value, not even the three in one
|
||||||
|
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||||
|
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||||
|
two gate phases and `script/fmt-check`, and builds no image of its own. Every
|
||||||
|
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||||
|
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
||||||
|
are compared by content and need nothing. A stage without the `ARG` line is
|
||||||
|
served from cache on an unchanged tree, so a repository re-vendors the scripts
|
||||||
|
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
|
||||||
|
it reruns every step too, but it also empties every cache mount, and Go's
|
||||||
|
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
||||||
|
packages survive between check builds, would start empty every time. A bare
|
||||||
|
`docker build .` is not evidence that anything ran: a sub-second build
|
||||||
|
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
||||||
|
`docker builder prune` and friends destroy a build cache shared with every
|
||||||
|
other build on the host. When a check is added or changed, prove it works by
|
||||||
|
planting a defect it must catch and watching the run fail on it, then revert
|
||||||
|
the defect. A green run alone shows neither that the check ran nor that it
|
||||||
|
covers what it should.
|
||||||
|
|
||||||
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||||
|
# on every build, which reruns every RUN step after it. The steps that
|
||||||
|
# compile mount Go's build cache, which survives from build to build.
|
||||||
|
|
||||||
|
# Lint phase
|
||||||
# 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
|
||||||
|
ARG CACHEBUST
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make fmt-check
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
RUN make lint
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
|
# image ships and the alpine one does not. -count=1 because the mounted
|
||||||
|
# cache also holds test results, which go test would otherwise replay.
|
||||||
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
|
ARG CACHEBUST
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS builder
|
FROM golang@sha256:... AS builder
|
||||||
WORKDIR /src
|
ARG CACHEBUST
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make test
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
# 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
|
# 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"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
includes both Go and the linter), so there is no need to install the
|
both Go and the linter), so nothing needs installing.
|
||||||
linter separately.
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
lines a red gate would not fail the build.
|
||||||
failure might not fail the overall build.
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
|
and nothing else.
|
||||||
|
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
||||||
|
it for root), and only on the steps that compile: `golangci-lint run`,
|
||||||
|
both `go test` runs and `go build`. It keeps compiled packages between
|
||||||
|
check builds, and Go reuses one only when all its inputs are unchanged, so
|
||||||
|
it saves the compile without skipping a check. Nothing else is kept:
|
||||||
|
golangci-lint's own cache would replay stored findings in place of a lint
|
||||||
|
run, and Go builds from whatever files a kept module cache holds without
|
||||||
|
checking them again (only `go mod verify` does), so a file changed there
|
||||||
|
would go unnoticed. Go's build cache also holds test results, which is why
|
||||||
|
both `go test` runs carry `-count=1`.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
The lint stage should not depend on the actual build output — it exists to
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
fail fast.
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
build stage, not the lint stage, because they may require compiled
|
|
||||||
artifacts or heavier dependencies.
|
```dockerfile
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||||
|
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||||
|
submodule keeping its own `.git` directory. `git describe` does not need
|
||||||
|
them, and each can hold a credential: a password in a remote URL, or the
|
||||||
|
token the CI checkout step stores there. A submodule whose name has a
|
||||||
|
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||||
|
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||||
|
then fails the build: give it a name without that segment
|
||||||
|
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||||
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
A checkout whose `.git` is a file (a linked worktree, or a repository
|
||||||
|
checked out as a submodule) is the exception: that file points to a git
|
||||||
|
directory outside the build context, so the build cannot read the version
|
||||||
|
and a plain `docker build .` fails; pass the version with
|
||||||
|
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
||||||
|
do.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||||
pass.
|
without it the checkout leaves the job's token in `.git/config` for every
|
||||||
|
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
|
||||||
|
tags `git describe` needs: by default it clones shallow with no tags, and a
|
||||||
|
tagged repository's CI build would stamp a bare short commit id. The
|
||||||
|
workflow's `concurrency` block groups runs by workflow and branch
|
||||||
|
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
|
||||||
|
so a new push cancels the older run on the same branch, queued or running, and
|
||||||
|
no other: runs for replaced commits do not hold up the shared runner.
|
||||||
|
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
|
||||||
|
so a successful run means every check passed; a bare `docker build .` does not
|
||||||
|
carry the same guarantee, because its gate phases may come from the cache. The
|
||||||
|
image build gets a `CACHEBUST` value of its own and so runs the gate phases a
|
||||||
|
second time. That is the price of the rule above, and it is worth paying: the
|
||||||
|
image that ships is built from a run of its own gates rather than from a cache
|
||||||
|
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
|
||||||
|
shared runner after 20 minutes. That allows for the three Docker builds
|
||||||
|
described above (the test phase, the lint phase, then the image), each held to
|
||||||
|
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
|
||||||
|
limited to `main` by a `branches` list under `on: push` cannot be checked by
|
||||||
|
review: to try a change to it, add the feature branch to that list and push,
|
||||||
|
then remove the branch from the list again before merging. Keep any job in it
|
||||||
|
that publishes behind `if: github.ref_name == 'main'`, so the run from the
|
||||||
|
feature branch publishes nothing.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -189,14 +375,21 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||||
Makefile.
|
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||||
|
20 and 60 seconds is still green, but the overage must be filed as an
|
||||||
|
improvement bug against that repo. Add a 90-second timeout to the test
|
||||||
|
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||||
|
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||||
|
one.
|
||||||
|
|
||||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
detail on failure (every test case, every assertion). The command lives in the
|
||||||
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||||
|
Makefile form below is the same pattern for any repo-local invocation:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -209,11 +402,25 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 30s -race -cover ./... || \
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 30s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so neither run can report a stored pass in place of running the
|
||||||
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
|
and no recompilation.
|
||||||
|
|
||||||
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
|
without running them. The directory lasts from one run to the next on a
|
||||||
|
developer's machine, where this target runs, and in the `test` phase of the
|
||||||
|
`Dockerfile`, which mounts it to keep compiled packages between check
|
||||||
|
builds, so the phase's `go test` runs carry `-count=1` too. A new
|
||||||
|
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -239,10 +446,89 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
`node_modules/`, and the repo's own build outputs. Fetch the standard
|
||||||
|
`.gitignore` from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
|
||||||
|
own entries, such as its binaries; a re-vendor replaces the standard part and
|
||||||
|
keeps those entries. These patterns are written to `.gitignore`'s own
|
||||||
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||||
|
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||||
|
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||||
|
therefore excludes only the copies at the repository root, while `config/.env`
|
||||||
|
and `certs/server.key` still reach the context and can land in an image layer
|
||||||
|
— which is more dangerous than a short file with no secret patterns at all,
|
||||||
|
because it reads as solved and stops anyone looking. Give every
|
||||||
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
|
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||||
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||||
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||||
|
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||||
|
also catches something the build needs, re-include it with a negation
|
||||||
|
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||||
|
other file it covers. Fetch the standard `.dockerignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
|
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||||
|
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||||
|
additional checkout of the repo — so under `COPY . .` the build context
|
||||||
|
inflates by a multiple of the repo and another session's unreviewed work can
|
||||||
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||||
|
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||||
|
prefix, because the prefixed form would also delete any nested directory of
|
||||||
|
that name from the build. Anchoring carries a known gap that the canonical
|
||||||
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||||
|
file and not the tracker: the directory is created in the agent's working
|
||||||
|
directory, so a repo running agents in subdirectories still ships
|
||||||
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
|
- **A plain `docker build .` of a clone stamps the version that
|
||||||
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||||
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||||
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||||
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||||
|
the version they compute on the host; it takes precedence. They do this
|
||||||
|
byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The version and the tag each get their own line: a failing command
|
||||||
|
# substitution inside an argument does not trip `set -e`, so the inline
|
||||||
|
# form degrades to an empty constant.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
tag="$(script/projectname)"
|
||||||
|
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$tag" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||||
|
fallback is applied — a live check that fires on a build from an export with
|
||||||
|
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||||
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
|
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||||
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||||
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
|
the scripts stay byte-identical. One consequence for CI: the standard
|
||||||
|
checkout action clones shallow and fetches no tags, so the canonical
|
||||||
|
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
|
transfers only the delta from the previous build.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -258,9 +544,61 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||||
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||||
|
because it cannot be written once for every repo: the `deny` list of the
|
||||||
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
|
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||||
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||||
|
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||||
|
disagree about what the tool even is. The canonical form:
|
||||||
|
- compares the installed version against the pin over the **whole** version
|
||||||
|
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||||
|
running `2.12.2-rc1` and skips the install;
|
||||||
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
|
skipped one;
|
||||||
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||||
|
then through `PATH`, not through the directory the installer wrote to —
|
||||||
|
and fails naming the resolved path, since an install that a shadowing
|
||||||
|
binary hides succeeds while changing nothing any caller sees;
|
||||||
|
- is actually called, and prints the version on both success paths: a
|
||||||
|
function defined and never invoked has the same exit status and the same
|
||||||
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Node is the exception to the whole-token comparison: the canonical
|
||||||
|
`script/bootstrap` compares only its major version, because the node 22
|
||||||
|
alpine image the `Dockerfile` stages start from is not the exact pin, and
|
||||||
|
nvm cannot install a prebuilt node on alpine.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
|
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||||
|
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||||
|
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||||
|
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||||
|
|
||||||
- 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).
|
||||||
@@ -371,15 +709,21 @@ 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
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
`cmd/`
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -406,3 +750,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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Example Caddy config for running pixa behind Caddy; see "Deployment" in
|
||||||
|
# README.md. Replace images.example.com with pixa's public host name, and
|
||||||
|
# 127.0.0.1:8080 with the address Caddy reaches pixa on.
|
||||||
|
#
|
||||||
|
# Caddy gets and renews the TLS certificate for the host name, passes the
|
||||||
|
# Host, Origin and Referer headers on unchanged, sets X-Forwarded-For to the
|
||||||
|
# client's address, and waits for pixa's answer with no time limit of its
|
||||||
|
# own, so pixa's downstream_timeout is what ends a slow request.
|
||||||
|
|
||||||
|
images.example.com
|
||||||
|
|
||||||
|
# pixa asks for metrics.username and metrics.password on /metrics. This
|
||||||
|
# line also keeps it off the public address, for a scraper that reaches
|
||||||
|
# pixa directly; remove it to read /metrics through Caddy.
|
||||||
|
respond /metrics 404
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
@@ -34,9 +34,10 @@ maintenance_mode: false
|
|||||||
state_dir: ./data
|
state_dir: ./data
|
||||||
|
|
||||||
# SQLite database URL (default:
|
# SQLite database URL (default:
|
||||||
# file:<state_dir>/state.sqlite3?_journal_mode=WAL). An empty value aborts
|
# file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds
|
||||||
# startup; leave the key out to use the default.
|
# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the
|
||||||
# db_url: "file:./data/state.sqlite3?_journal_mode=WAL"
|
# key out to use the default.
|
||||||
|
# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
|
|
||||||
# Image proxy settings
|
# Image proxy settings
|
||||||
# HMAC signing key for URL signatures (required, at least 32 characters)
|
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||||
@@ -44,13 +45,24 @@ 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")
|
||||||
allowlist_hosts:
|
# An entry that is neither a host name nor an IP address (IPv6 without
|
||||||
- s3.sneak.cloud
|
# brackets), such as one with a port or a "*." wildcard, aborts startup.
|
||||||
- static.sneak.cloud
|
# allowlist_hosts:
|
||||||
- sneak.berlin
|
# - images.example.com
|
||||||
- github.com
|
|
||||||
- user-images.githubusercontent.com
|
# Hosts whose pages may not show pixa's images, written as for
|
||||||
|
# allowlist_hosts. A request to /v1/image/ or /v1/e/ whose Referer header
|
||||||
|
# names one of them is answered 403 before anything is fetched, even when
|
||||||
|
# the image is cached. A request with no Referer, or one that does not
|
||||||
|
# parse, is served, so a site whose pages send no Referer is not stopped.
|
||||||
|
# The login and generator pages are not covered. (default: none)
|
||||||
|
# referer_blocklist:
|
||||||
|
# - leech.example
|
||||||
|
# - .hotlinker.example
|
||||||
|
|
||||||
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
||||||
# SSRF protection. These are added to the always-enforced built-in ranges
|
# SSRF protection. These are added to the always-enforced built-in ranges
|
||||||
@@ -96,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
|
||||||
|
|
||||||
@@ -128,8 +149,9 @@ access_control_allow_origin: "*"
|
|||||||
|
|
||||||
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
||||||
# given; 0 disables the disk cache entirely (every request fetches and
|
# given; 0 disables the disk cache entirely (every request fetches and
|
||||||
# processes uncached). When omitted, the default is 75% of the free
|
# processes uncached). When omitted, the default is 75% of the sum of
|
||||||
# space on the filesystem containing <state_dir>/cache/ at startup,
|
# the free space on the filesystem containing <state_dir>/cache/ and
|
||||||
|
# the bytes of images the cache already holds, worked out at startup,
|
||||||
# with a minimum of 500 MiB.
|
# with a minimum of 500 MiB.
|
||||||
# cache_max_bytes: 10737418240
|
# cache_max_bytes: 10737418240
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Package app reads the pixad command line and runs the server.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/handlers"
|
||||||
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configPath string //nolint:gochecknoglobals // cobra flag
|
||||||
|
|
||||||
|
// Run reads the command line and runs the server until it stops, with
|
||||||
|
// version as the version pixad logs and reports. It exits the process
|
||||||
|
// with status 1 when the command line is not valid.
|
||||||
|
func Run(version string) {
|
||||||
|
globals.Version = version
|
||||||
|
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "pixad",
|
||||||
|
Short: "Pixa image caching proxy server",
|
||||||
|
Run: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ *cobra.Command, _ []string) {
|
||||||
|
// Set config path in environment if specified via flag
|
||||||
|
if configPath != "" {
|
||||||
|
_ = os.Setenv("PIXA_CONFIG_PATH", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write to a closed stdout or stderr must not end the process.
|
||||||
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
|
|
||||||
|
fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
config.New,
|
||||||
|
database.New,
|
||||||
|
globals.New,
|
||||||
|
handlers.New,
|
||||||
|
logger.New,
|
||||||
|
server.New,
|
||||||
|
middleware.New,
|
||||||
|
healthcheck.New,
|
||||||
|
),
|
||||||
|
fx.Invoke(
|
||||||
|
func(log *logger.Logger) { log.Identify() },
|
||||||
|
func(*server.Server) {},
|
||||||
|
),
|
||||||
|
).Run()
|
||||||
|
}
|
||||||
@@ -1,26 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Static errors returned by the stub free-space probes below.
|
|
||||||
var (
|
|
||||||
errTestStatfsFailed = errors.New("statfs failed")
|
|
||||||
errTestProbeNotExpected = errors.New("probe must not be called")
|
|
||||||
)
|
|
||||||
|
|
||||||
// discardLogger returns a logger that swallows all output, for tests
|
|
||||||
// that exercise code paths which log.
|
|
||||||
func discardLogger() *slog.Logger {
|
|
||||||
return slog.New(slog.DiscardHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
||||||
// explicitly configured cache_max_bytes value is used exactly as
|
// explicitly configured cache_max_bytes value is used exactly as
|
||||||
// given: the 500 MiB floor applies only to the computed default, never
|
// given: the 500 MiB floor applies only to the computed default, never
|
||||||
@@ -151,155 +135,30 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
|
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
||||||
// computed default is 75% of the probed free space when that exceeds
|
// cache_max_bytes is recorded as not explicit, so the cache works out
|
||||||
// the floor.
|
// the default when it opens, and that an explicit zero is recorded as
|
||||||
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
// explicit, so it disables the disk cache instead.
|
||||||
|
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// 4 GiB free -> 3 GiB default.
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
probe := func(string) (uint64, error) { return 4294967296, nil }
|
|
||||||
|
|
||||||
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
omitted, err := configFromYAML(t, signingKeyLine)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != 3221225472 {
|
|
||||||
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)",
|
|
||||||
got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault
|
|
||||||
// verifies that when 75% of free space is below 500 MiB, the computed
|
|
||||||
// default is floored at DefaultCacheMaxBytesFloor.
|
|
||||||
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
freeBytes uint64
|
|
||||||
}{
|
|
||||||
{name: "100 MiB free", freeBytes: 104857600},
|
|
||||||
{name: "zero free", freeBytes: 0},
|
|
||||||
{name: "just below floor threshold", freeBytes: 699050665},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
|
|
||||||
|
|
||||||
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != DefaultCacheMaxBytesFloor {
|
|
||||||
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want floor %d",
|
|
||||||
got, DefaultCacheMaxBytesFloor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestComputeDefaultCacheMaxBytesPropagatesProbeError verifies that a
|
|
||||||
// failing free-space probe produces an error naming the config key,
|
|
||||||
// instead of a silently wrong default.
|
|
||||||
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
probe := func(string) (uint64, error) { return 0, errTestStatfsFailed }
|
|
||||||
|
|
||||||
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("probe failure must produce an error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("got expected error: %v", err)
|
|
||||||
|
|
||||||
if !strings.Contains(err.Error(), keyCacheMaxBytes) {
|
|
||||||
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveCacheMaxBytesComputesDefaultWhenOmitted verifies that an
|
|
||||||
// omitted cache_max_bytes key resolves to the computed default, that
|
|
||||||
// the probe is pointed at <state_dir>/cache/ (which must be created
|
|
||||||
// first so statfs measures the right filesystem), and that the result
|
|
||||||
// lands on the Config.
|
|
||||||
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.StateDir = t.TempDir()
|
if omitted.CacheMaxBytesExplicit {
|
||||||
wantCacheDir := filepath.Join(c.StateDir, "cache")
|
t.Error("omitted cache_max_bytes recorded as explicit")
|
||||||
|
|
||||||
var probedPath string
|
|
||||||
|
|
||||||
// 4 GiB free -> 3 GiB default.
|
|
||||||
probe := func(path string) (uint64, error) {
|
|
||||||
probedPath = path
|
|
||||||
|
|
||||||
return 4294967296, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
zero, err := configFromYAML(t, signingKeyLine+"cache_max_bytes: 0\n")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CacheMaxBytes != 3221225472 {
|
if !zero.CacheMaxBytesExplicit {
|
||||||
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472",
|
t.Error("cache_max_bytes: 0 not recorded as explicit")
|
||||||
c.CacheMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if probedPath != wantCacheDir {
|
|
||||||
t.Errorf("free space probed at %q, want cache directory %q",
|
|
||||||
probedPath, wantCacheDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(wantCacheDir)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
|
||||||
wantCacheDir, info, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveCacheMaxBytesDoesNotOverrideExplicitValue verifies that
|
|
||||||
// an explicitly configured value survives resolution untouched and
|
|
||||||
// that the free-space probe is never consulted for it.
|
|
||||||
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, yamlContent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.StateDir = t.TempDir()
|
|
||||||
|
|
||||||
probe := func(string) (uint64, error) {
|
|
||||||
t.Error("free-space probe must not be consulted for explicit values")
|
|
||||||
|
|
||||||
return 0, errTestProbeNotExpected
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.CacheMaxBytes != 1024 {
|
|
||||||
t.Errorf("CacheMaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
|
||||||
c.CacheMaxBytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultCacheMaxBytesFloor is the minimum computed default for the
|
|
||||||
// cache_max_bytes setting: 500 MiB. The floor applies only to the
|
|
||||||
// computed default (when the key is omitted from the configuration),
|
|
||||||
// never to explicitly configured values.
|
|
||||||
const DefaultCacheMaxBytesFloor int64 = 524288000
|
|
||||||
|
|
||||||
// cacheDirPerms is the permission mode for the cache directory created
|
|
||||||
// before probing free space, matching the state directory permissions.
|
|
||||||
const cacheDirPerms = 0o750
|
|
||||||
|
|
||||||
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
|
|
||||||
// the 75% share of free space used for the computed default limit as
|
|
||||||
// integer arithmetic (dividing before multiplying avoids overflow).
|
|
||||||
const (
|
|
||||||
freeSpaceFractionNumerator uint64 = 3
|
|
||||||
freeSpaceFractionDenominator uint64 = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
// FreeSpaceProbeFunc reports the number of free bytes available on the
|
|
||||||
// filesystem containing path. It is a function type so tests can
|
|
||||||
// inject a fake probe instead of depending on the host disk.
|
|
||||||
type FreeSpaceProbeFunc func(path string) (uint64, error)
|
|
||||||
|
|
||||||
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
|
|
||||||
// the given path, as available to unprivileged processes.
|
|
||||||
func defaultFreeSpaceProbe(path string) (uint64, error) {
|
|
||||||
var stat syscall.Statfs_t
|
|
||||||
|
|
||||||
err := syscall.Statfs(path, &stat)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stat.Bsize < 0 {
|
|
||||||
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
blockSize := uint64(stat.Bsize)
|
|
||||||
|
|
||||||
return stat.Bavail * blockSize, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
|
|
||||||
// the filesystem containing cacheDir: 75% of the free bytes reported
|
|
||||||
// by probe, with a floor of DefaultCacheMaxBytesFloor.
|
|
||||||
func ComputeDefaultCacheMaxBytes(
|
|
||||||
cacheDir string, probe FreeSpaceProbeFunc,
|
|
||||||
) (int64, error) {
|
|
||||||
freeBytes, err := probe(cacheDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w",
|
|
||||||
"cache_max_bytes", cacheDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
|
||||||
computed = min(computed, math.MaxInt64)
|
|
||||||
|
|
||||||
// gosec cannot see that min() above bounds computed, so it reads
|
|
||||||
// this conversion as potentially overflowing. It cannot: computed is
|
|
||||||
// at most math.MaxInt64 on every path here.
|
|
||||||
//nolint:gosec // G115: clamped to MaxInt64 by min above
|
|
||||||
limit := int64(computed)
|
|
||||||
limit = max(limit, DefaultCacheMaxBytesFloor)
|
|
||||||
|
|
||||||
return limit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveCacheMaxBytes finalizes CacheMaxBytes after state_dir
|
|
||||||
// validation: an explicitly configured value is kept as-is (no floor
|
|
||||||
// applies), while an omitted key receives the computed default based
|
|
||||||
// on free space in <state_dir>/cache/. The cache directory is created
|
|
||||||
// first so statfs measures the filesystem that will actually hold the
|
|
||||||
// cache. The effective limit is logged either way.
|
|
||||||
func (c *Config) resolveCacheMaxBytes(
|
|
||||||
log *slog.Logger, probe FreeSpaceProbeFunc,
|
|
||||||
) error {
|
|
||||||
if !c.cacheMaxBytesExplicit {
|
|
||||||
cacheDir := filepath.Join(c.StateDir, "cache")
|
|
||||||
|
|
||||||
err := os.MkdirAll(cacheDir, cacheDirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
|
|
||||||
keyCacheMaxBytes, cacheDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.CacheMaxBytes = limit
|
|
||||||
|
|
||||||
log.Info("computed default cache size limit from free space",
|
|
||||||
"cache_max_bytes", limit,
|
|
||||||
"cache_dir", cacheDir,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("effective cache size limit",
|
|
||||||
"cache_max_bytes", c.CacheMaxBytes,
|
|
||||||
"cache_disabled", c.CacheMaxBytes == 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+180
-81
@@ -4,16 +4,19 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -31,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"
|
||||||
@@ -46,6 +58,7 @@ const (
|
|||||||
keyMetricsPassword = "metrics.password"
|
keyMetricsPassword = "metrics.password"
|
||||||
keySigningKey = "signing_key"
|
keySigningKey = "signing_key"
|
||||||
keyAllowlistHosts = "allowlist_hosts"
|
keyAllowlistHosts = "allowlist_hosts"
|
||||||
|
keyRefererBlocklist = "referer_blocklist"
|
||||||
keyAllowHTTP = "allow_http"
|
keyAllowHTTP = "allow_http"
|
||||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
keyUpstreamConnections = "upstream_connections"
|
keyUpstreamConnections = "upstream_connections"
|
||||||
@@ -57,10 +70,11 @@ const (
|
|||||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||||
keyDownstreamTimeout = "downstream_timeout"
|
keyDownstreamTimeout = "downstream_timeout"
|
||||||
|
keyImageRequestsPerMinute = "image_requests_per_minute"
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
// config.example.yml. It is 45 characters, so it passes the length
|
// configs/config.example.yml. It is 45 characters, so it passes the length
|
||||||
// check, but it is public in this repository and must be rejected at
|
// check, but it is public in this repository and must be rejected at
|
||||||
// startup so no deployment ever signs URLs with it.
|
// startup so no deployment ever signs URLs with it.
|
||||||
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
@@ -86,23 +100,20 @@ var (
|
|||||||
errMustBeAtLeastOne = errors.New("must be at least 1")
|
errMustBeAtLeastOne = errors.New("must be at least 1")
|
||||||
errValueTooShort = errors.New("value too short")
|
errValueTooShort = errors.New("value too short")
|
||||||
errPlaceholderKey = errors.New(
|
errPlaceholderKey = errors.New(
|
||||||
"is the placeholder from config.example.yml; " +
|
"is the placeholder from configs/config.example.yml; " +
|
||||||
"generate a real key with: openssl rand -base64 32")
|
"generate a real key with: openssl rand -base64 32")
|
||||||
errMustBeSetTogether = errors.New("must be set together")
|
errMustBeSetTogether = errors.New("must be set together")
|
||||||
errMustNotBeNegative = errors.New("must not be negative")
|
errMustNotBeNegative = errors.New("must not be negative")
|
||||||
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
||||||
errNegativeBlockSize = errors.New(
|
errValueNull = errors.New(
|
||||||
"statfs reported negative block size")
|
|
||||||
errValueNull = errors.New(
|
|
||||||
"value is null; omit the key entirely to use the default")
|
"value is null; omit the key entirely to use the default")
|
||||||
errValuesNull = errors.New(
|
errValuesNull = errors.New(
|
||||||
"value is null; omit a key entirely to use its default")
|
"value is null; omit a key entirely to use its default")
|
||||||
errNotBareHostname = errors.New(
|
errNotAHost = errors.New("must be a host name such as " +
|
||||||
"must be a bare hostname without scheme, path, or whitespace")
|
"cdn.example.com or .example.com, or an IP address")
|
||||||
errNoHostnameLabels = errors.New("contains no hostname labels")
|
errNotADuration = errors.New("not a duration such as 30s or 2m")
|
||||||
errNotADuration = errors.New("not a duration such as 30s or 2m")
|
errMustBePositive = errors.New("must be positive")
|
||||||
errMustBePositive = errors.New("must be positive")
|
errNotAnOrigin = errors.New(
|
||||||
errNotAnOrigin = errors.New(
|
|
||||||
`not "*" or an origin such as https://example.com`)
|
`not "*" or an origin such as https://example.com`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,6 +141,10 @@ type Config struct {
|
|||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
|
|
||||||
|
// RefererBlocklist holds host patterns, matched as AllowlistHosts is: the
|
||||||
|
// image routes refuse a request whose Referer names a matching host.
|
||||||
|
RefererBlocklist []string
|
||||||
|
|
||||||
// UpstreamConnections is the most concurrent connections to all
|
// UpstreamConnections is the most concurrent connections to all
|
||||||
// upstream hosts together, on top of the per-host limit.
|
// upstream hosts together, on top of the per-host limit.
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
@@ -153,6 +168,11 @@ type Config struct {
|
|||||||
// of the per-request timeout middleware.
|
// of the per-request timeout middleware.
|
||||||
DownstreamTimeout time.Duration
|
DownstreamTimeout time.Duration
|
||||||
|
|
||||||
|
// ImageRequestsPerMinute is how many requests one client may make to
|
||||||
|
// the image routes per minute; the next is refused with 429 Too Many
|
||||||
|
// Requests. 0 turns the limit off.
|
||||||
|
ImageRequestsPerMinute int
|
||||||
|
|
||||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||||
// fetcher's dialer; the built-in ranges always apply.
|
// fetcher's dialer; the built-in ranges always apply.
|
||||||
@@ -169,18 +189,19 @@ type Config struct {
|
|||||||
// address, and an explicit list replaces the default.
|
// address, and an explicit list replaces the default.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
// CacheMaxBytes is the disk cache size limit in bytes. Only an
|
||||||
// disables the disk cache entirely. When cache_max_bytes is
|
// explicit zero (CacheMaxBytesExplicit true) disables the disk
|
||||||
// omitted from the configuration, this holds the computed default
|
// cache. Zero with CacheMaxBytesExplicit false means
|
||||||
// (75% of free space on the filesystem containing
|
// cache_max_bytes was omitted, and the cache works out the default
|
||||||
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
|
// limit when it opens.
|
||||||
CacheMaxBytes int64
|
CacheMaxBytes int64
|
||||||
|
|
||||||
// cacheMaxBytesExplicit records whether cache_max_bytes was
|
// CacheMaxBytesExplicit records whether cache_max_bytes was
|
||||||
// explicitly set, in the environment or the configuration file.
|
// explicitly set, in the environment or the configuration file.
|
||||||
// Explicit values are used exactly as given; only an omitted key
|
// Explicit values are used exactly as given; for an omitted key the
|
||||||
// gets the computed default (and its floor) in resolveCacheMaxBytes.
|
// cache works out the default limit when it opens (see
|
||||||
cacheMaxBytesExplicit bool
|
// imgcache.CacheConfig.UseDefaultMaxBytes).
|
||||||
|
CacheMaxBytesExplicit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config instance from the environment and the
|
// New creates a new Config instance from the environment and the
|
||||||
@@ -217,9 +238,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
|
// An omitted cache_max_bytes is worked out and logged when the
|
||||||
if err != nil {
|
// cache opens.
|
||||||
return nil, err
|
if c.CacheMaxBytesExplicit {
|
||||||
|
log.Info("effective cache size limit",
|
||||||
|
"cache_max_bytes", c.CacheMaxBytes,
|
||||||
|
"cache_disabled", c.CacheMaxBytes == 0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Debug {
|
if c.Debug {
|
||||||
@@ -265,7 +290,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loader := &strictLoader{sc: sc}
|
loader := &strictLoader{sc: sc}
|
||||||
|
|
||||||
c := &Config{
|
c := &Config{
|
||||||
Debug: loader.boolVal(keyDebug, false),
|
Debug: loader.boolVal(keyDebug, false),
|
||||||
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
|
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
|
||||||
@@ -281,10 +305,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(
|
||||||
@@ -293,17 +315,18 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
||||||
DownstreamTimeout: loader.durationVal(
|
DownstreamTimeout: loader.durationVal(
|
||||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
ImageRequestsPerMinute: loader.intVal(
|
||||||
BlockedNetworks: blockedNetworks,
|
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
|
||||||
TrustedProxies: trustedProxies,
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
|
BlockedNetworks: blockedNetworks,
|
||||||
|
TrustedProxies: trustedProxies,
|
||||||
|
RefererBlocklist: loader.hostListVal(keyRefererBlocklist),
|
||||||
}
|
}
|
||||||
|
|
||||||
// The computed default for cache_max_bytes needs a validated
|
// The default for an omitted cache_max_bytes is worked out when
|
||||||
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
|
// the cache opens; here we only record whether the operator set
|
||||||
// we only record whether the operator set the key explicitly.
|
// the key explicitly.
|
||||||
if _, present := lookupValue(sc, keyCacheMaxBytes); present {
|
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
|
||||||
c.cacheMaxBytesExplicit = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
// is a default: it applies only when db_url is omitted, never to an
|
// is a default: it applies only when db_url is omitted, never to an
|
||||||
@@ -315,7 +338,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
settingName(keyDBURL), errValueEmpty)
|
settingName(keyDBURL), errValueEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
// The driver sets the journal mode only through a _pragma parameter.
|
||||||
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loader.err != nil {
|
if loader.err != nil {
|
||||||
@@ -414,7 +438,8 @@ func isKnownConfigKey(key string) bool {
|
|||||||
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
|
keyUpstreamConnectionsPerHost, keyUpstreamConnections,
|
||||||
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
|
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
||||||
|
keyImageRequestsPerMinute, "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +462,7 @@ func envVarNames() map[string]string {
|
|||||||
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
|
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
|
||||||
keySigningKey: "PIXA_SIGNING_KEY",
|
keySigningKey: "PIXA_SIGNING_KEY",
|
||||||
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
|
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
|
||||||
|
keyRefererBlocklist: "PIXA_REFERER_BLOCKLIST",
|
||||||
keyAllowHTTP: "PIXA_ALLOW_HTTP",
|
keyAllowHTTP: "PIXA_ALLOW_HTTP",
|
||||||
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
|
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
|
||||||
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
|
keyUpstreamConnections: "PIXA_UPSTREAM_CONNECTIONS",
|
||||||
@@ -448,6 +474,7 @@ func envVarNames() map[string]string {
|
|||||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
||||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
||||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
||||||
|
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,8 +575,8 @@ func (c *Config) ensureStateDirWritable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateSigningKey checks that the signing key is present, long
|
// validateSigningKey checks that the signing key is present, long
|
||||||
// enough, and not the public placeholder from config.example.yml. The
|
// enough, and not the public placeholder from configs/config.example.yml.
|
||||||
// key value itself is never echoed in error messages.
|
// The key value itself is never echoed in error messages.
|
||||||
func (c *Config) validateSigningKey() error {
|
func (c *Config) validateSigningKey() error {
|
||||||
if c.SigningKey == "" {
|
if c.SigningKey == "" {
|
||||||
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
|
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
|
||||||
@@ -600,24 +627,29 @@ func (c *Config) validate() error {
|
|||||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero is valid (it turns the limit off); only negative values are
|
||||||
|
// rejected.
|
||||||
|
if c.ImageRequestsPerMinute < 0 {
|
||||||
|
return fmt.Errorf("%s: value %d %w",
|
||||||
|
settingName(keyImageRequestsPerMinute), c.ImageRequestsPerMinute,
|
||||||
|
errMustNotBeNegative)
|
||||||
|
}
|
||||||
|
|
||||||
err = c.validateUpstreamMaxResponseSize()
|
err = c.validateUpstreamMaxResponseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, host := range c.AllowlistHosts {
|
for _, host := range c.AllowlistHosts {
|
||||||
err := validateAllowlistHost(host)
|
err := validateHostPattern(keyAllowlistHosts, host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.SentryDSN != "" {
|
err = c.validateSentryDSN()
|
||||||
parsed, err := url.Parse(c.SentryDSN)
|
if err != nil {
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
return err
|
||||||
return fmt.Errorf("%s: value %q is %w",
|
|
||||||
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||||
@@ -629,6 +661,22 @@ func (c *Config) validate() error {
|
|||||||
return c.validateAccessControlAllowOrigin()
|
return c.validateAccessControlAllowOrigin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme
|
||||||
|
// and a host.
|
||||||
|
func (c *Config) validateSentryDSN() error {
|
||||||
|
if c.SentryDSN == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(c.SentryDSN)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("%s: value %q is %w",
|
||||||
|
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
||||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
||||||
// memory, and the image processor reads one byte past this limit, which
|
// memory, and the image processor reads one byte past this limit, which
|
||||||
@@ -729,25 +777,24 @@ func (c *Config) validateConcurrencyLimits() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
// hostNamePattern matches a host name: letters, digits, hyphens, underscores
|
||||||
// hostname, optionally with a leading dot for suffix matching. URLs,
|
// and dots, optionally after one leading dot.
|
||||||
// paths, and whitespace indicate a misconfigured entry. An entry with
|
var hostNamePattern = regexp.MustCompile(`^\.?[A-Za-z0-9_-][A-Za-z0-9_.-]*$`)
|
||||||
// no hostname labels (such as ".") is rejected: the allowlist matcher
|
|
||||||
// treats a leading dot as a suffix pattern, so a bare "." would match
|
// validateHostPattern checks that an entry of the named key, allowlist_hosts
|
||||||
// any upstream host written in FQDN trailing-dot form and effectively
|
// or referer_blocklist, is an IP address or a host name, the host name
|
||||||
// disable URL signing.
|
// optionally with one leading dot for suffix matching. Anything else, such as
|
||||||
func validateAllowlistHost(host string) error {
|
// a URL, a port or a "*." wildcard, can never match a host name that resolves,
|
||||||
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
// so it is refused.
|
||||||
return fmt.Errorf("%s: entry %q %w",
|
// So is "." alone: the allowlist matcher would match it against any host
|
||||||
settingName(keyAllowlistHosts), host, errNotBareHostname)
|
// written with a trailing dot, which in allowlist_hosts disables URL signing.
|
||||||
|
func validateHostPattern(key, host string) error {
|
||||||
|
_, err := netip.ParseAddr(host)
|
||||||
|
if err == nil || hostNamePattern.MatchString(host) {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Trim(host, ".") == "" {
|
return fmt.Errorf("%s: entry %q %w", settingName(key), host, errNotAHost)
|
||||||
return fmt.Errorf("%s: entry %q %w",
|
|
||||||
settingName(keyAllowlistHosts), host, errNoHostnameLabels)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
||||||
@@ -778,19 +825,28 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
for _, path := range configPaths {
|
for _, path := range configPaths {
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Only a config file that does not exist is skipped, including
|
||||||
|
// one whose path runs through a file, such as under a HOME of
|
||||||
|
// /dev/null. One that cannot be read or does not parse is a
|
||||||
|
// fatal startup error.
|
||||||
|
//nolint:gosec // G703: a fixed config location; HOME is the operator's
|
||||||
_, statErr := os.Stat(cleanPath)
|
_, statErr := os.Stat(cleanPath)
|
||||||
if statErr == nil {
|
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||||
// A config file that exists but does not parse is a fatal
|
continue
|
||||||
// startup error, never something to skip over.
|
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("loaded config file", "path", path)
|
|
||||||
|
|
||||||
return sc, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if statErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("loaded config file", "path", path)
|
||||||
|
|
||||||
|
return sc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||||
@@ -869,6 +925,19 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
|||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *strictLoader) hostListVal(key string) []string {
|
||||||
|
if l.err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := parseHostList(l.sc, key)
|
||||||
|
if err != nil {
|
||||||
|
l.err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
// getString returns the string value for key, or defaultVal if the key
|
// getString returns the string value for key, or defaultVal if the key
|
||||||
// is omitted. A present value that is not a string, or is explicitly
|
// is omitted. A present value that is not a string, or is explicitly
|
||||||
// null, is an error.
|
// null, is an error.
|
||||||
@@ -1166,7 +1235,7 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return nil, errNullConfigValue(key)
|
return nil, errNullConfigValue(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := cidrListEntries(raw, key)
|
entries, err := listEntries(raw, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1186,11 +1255,41 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return prefixes, nil
|
return prefixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cidrListEntries extracts the raw entries of the named CIDR-list key as
|
// parseHostList parses the value of the named config key into host patterns,
|
||||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
// or returns nil if the key is omitted. It accepts a YAML list of strings or a
|
||||||
// comma-separated string; an empty string is an empty list, as for
|
// comma-separated string. An explicitly null value, a wrong type, an empty
|
||||||
// allowlist_hosts. Any other shape is a configuration error.
|
// entry, a non-string entry, or an entry validateHostPattern rejects aborts
|
||||||
func cidrListEntries(raw any, key string) ([]string, error) {
|
// startup naming the key and the offending value.
|
||||||
|
func parseHostList(sc *smartconfig.Config, key string) ([]string, error) {
|
||||||
|
raw, ok := lookupValue(sc, key)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return nil, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := listEntries(raw, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
err := validateHostPattern(key, entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listEntries extracts the raw entries of the named list key as trimmed,
|
||||||
|
// non-empty strings, from either a YAML list of strings or a comma-separated
|
||||||
|
// string; an empty string is an empty list, as for allowlist_hosts. Any other
|
||||||
|
// shape is a configuration error.
|
||||||
|
func listEntries(raw any, key string) ([]string, error) {
|
||||||
switch val := raw.(type) {
|
switch val := raw.(type) {
|
||||||
case []any:
|
case []any:
|
||||||
entries := make([]string, 0, len(val))
|
entries := make([]string, 0, len(val))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
)
|
)
|
||||||
|
|
||||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
@@ -94,12 +98,43 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
|
|||||||
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
wantDBURL := "file:" + DefaultStateDir +
|
||||||
|
"/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
if c.DBURL != wantDBURL {
|
if c.DBURL != wantDBURL {
|
||||||
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultDBURLOpensTheDatabaseInWALMode opens the db_url derived from
|
||||||
|
// state_dir with the SQLite driver pixad uses and checks that the database
|
||||||
|
// is in WAL mode: the driver ignores any parameter it does not know.
|
||||||
|
func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+t.TempDir()+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with only state_dir set should be valid, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", c.DBURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open %q: %v", c.DBURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
var journalMode string
|
||||||
|
|
||||||
|
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&journalMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read the journal mode of %q: %v", c.DBURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalMode != "wal" {
|
||||||
|
t.Errorf("journal mode of %q = %q, want wal", c.DBURL, journalMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -182,6 +217,25 @@ func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAllowlistHostsAcceptsUnderscore checks that an upstream host name with
|
||||||
|
// an underscore, which pixa can fetch from, is accepted as an entry.
|
||||||
|
func TestAllowlistHostsAcceptsUnderscore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+`allowlist_hosts:
|
||||||
|
- my_bucket.example.com
|
||||||
|
- .my_bucket.example.org
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("host names with an underscore should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"my_bucket.example.com", ".my_bucket.example.org"}
|
||||||
|
if !slices.Equal(c.AllowlistHosts, want) {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want %v", c.AllowlistHosts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// runAbortCases asserts that each case's config aborts startup with an
|
// runAbortCases asserts that each case's config aborts startup with an
|
||||||
// error message mentioning every expected substring.
|
// error message mentioning every expected substring.
|
||||||
func runAbortCases(t *testing.T, cases []abortCase) {
|
func runAbortCases(t *testing.T, cases []abortCase) {
|
||||||
@@ -284,6 +338,20 @@ func invalidHostAndCredentialCases() []abortCase {
|
|||||||
keyAllowlistHosts, "example.com/images",
|
keyAllowlistHosts, "example.com/images",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with wildcard",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"*.example.com\"\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyAllowlistHosts, "*.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with port",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - example.com:8443\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyAllowlistHosts, "example.com:8443",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "allowlist host with whitespace",
|
name: "allowlist host with whitespace",
|
||||||
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||||
@@ -564,6 +632,116 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
|||||||
t.Logf("got expected error: %v", err)
|
t.Logf("got expected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup checks that a
|
||||||
|
// config file pixa cannot read because it may not enter its directory
|
||||||
|
// aborts startup instead of being passed over.
|
||||||
|
func TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root may enter any directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
configDir := filepath.Join(home, ".config", "pixa-test-nonexistent-app")
|
||||||
|
configPath := filepath.Join(configDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.MkdirAll(configDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create config directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(configPath, []byte(signingKeyLine), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Chmod(configDir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to remove the config directory's permissions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the directory back its permissions so t.TempDir can remove it.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
//nolint:gosec // G302: a directory needs its execute bit to be removed
|
||||||
|
_ = os.Chmod(configDir, 0o700)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ~/.config candidate is the only one that exists: the appname
|
||||||
|
// rules out /etc, and the working directory is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||||
|
sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), configPath) {
|
||||||
|
t.Errorf("error %q does not name the config file %s", err.Error(), configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigFileLinkingToItselfAbortsStartup checks that a config file
|
||||||
|
// pixa cannot read for a reason other than not existing aborts startup,
|
||||||
|
// as root too: a symbolic link to itself fails with "too many levels of
|
||||||
|
// symbolic links".
|
||||||
|
func TestConfigFileLinkingToItselfAbortsStartup(t *testing.T) {
|
||||||
|
workDir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.Symlink("config.yml", filepath.Join(workDir, "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create symbolic link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the working directory's config.yml is there: the appname rules
|
||||||
|
// out /etc, and HOME is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Chdir(workDir)
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||||
|
sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "config.yml") {
|
||||||
|
t.Errorf("error %q does not name the config file config.yml", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigPathThroughFileIsPassedOver checks that a config file path
|
||||||
|
// that runs through a file, such as one under a HOME of /dev/null, is
|
||||||
|
// passed over like one that does not exist, since no file can be there.
|
||||||
|
func TestConfigPathThroughFileIsPassedOver(t *testing.T) {
|
||||||
|
// No config file is there: the appname rules out /etc, HOME is
|
||||||
|
// /dev/null, and the working directory is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", os.DevNull)
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a config path through a file must be passed over, got error: %v",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc != nil {
|
||||||
|
t.Errorf("expected no config file, got config: %v", sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,12 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
|
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
|
||||||
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
||||||
|
t.Setenv("PIXA_REFERER_BLOCKLIST", "hotlinker.example,.leech.example")
|
||||||
t.Setenv("PIXA_ALLOW_HTTP", "true")
|
t.Setenv("PIXA_ALLOW_HTTP", "true")
|
||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||||
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
||||||
|
t.Setenv("PIXA_IMAGE_REQUESTS_PER_MINUTE", "30")
|
||||||
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||||
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||||
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||||
@@ -93,12 +95,14 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
MetricsPassword: "metricspass",
|
MetricsPassword: "metricspass",
|
||||||
SigningKey: validTestSigningKey,
|
SigningKey: validTestSigningKey,
|
||||||
AllowlistHosts: []string{testHostS3, ".example.com"},
|
AllowlistHosts: []string{testHostS3, ".example.com"},
|
||||||
|
RefererBlocklist: []string{"hotlinker.example", ".leech.example"},
|
||||||
AllowHTTP: true,
|
AllowHTTP: true,
|
||||||
UpstreamConnectionsPerHost: 5,
|
UpstreamConnectionsPerHost: 5,
|
||||||
UpstreamConnections: 10,
|
UpstreamConnections: 10,
|
||||||
MaxConcurrentProcessing: 3,
|
MaxConcurrentProcessing: 3,
|
||||||
|
ImageRequestsPerMinute: 30,
|
||||||
CacheMaxBytes: 1024,
|
CacheMaxBytes: 1024,
|
||||||
cacheMaxBytesExplicit: true,
|
CacheMaxBytesExplicit: true,
|
||||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
AccessControlAllowOrigin: "https://app.example.com",
|
AccessControlAllowOrigin: "https://app.example.com",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// testImageRequestsPerMinuteVar is the variable that sets
|
||||||
|
// image_requests_per_minute.
|
||||||
|
const testImageRequestsPerMinuteVar = "PIXA_IMAGE_REQUESTS_PER_MINUTE"
|
||||||
|
|
||||||
|
// TestImageRequestsPerMinuteValues checks that an omitted
|
||||||
|
// image_requests_per_minute is 600, and that 0, which turns the limit off,
|
||||||
|
// and any larger whole number are used as given.
|
||||||
|
func TestImageRequestsPerMinuteValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.ImageRequestsPerMinute != 600 {
|
||||||
|
t.Errorf("ImageRequestsPerMinute = %d, want 600", c.ImageRequestsPerMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit := []struct {
|
||||||
|
value string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"0", 0},
|
||||||
|
{"30", 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range explicit {
|
||||||
|
c, err := configFromYAML(t,
|
||||||
|
signingKeyLine+"image_requests_per_minute: "+tc.value+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("image_requests_per_minute: %s should load, got error: %v",
|
||||||
|
tc.value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.ImageRequestsPerMinute != tc.want {
|
||||||
|
t.Errorf("ImageRequestsPerMinute = %d, want %d",
|
||||||
|
c.ImageRequestsPerMinute, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidImageRequestsPerMinuteAbortsStartup checks that a value that is
|
||||||
|
// negative, not a whole number, or null aborts startup naming the key and
|
||||||
|
// the value, and the variable too where the value could have come from it.
|
||||||
|
func TestInvalidImageRequestsPerMinuteAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := keyImageRequestsPerMinute
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "negative",
|
||||||
|
yaml: signingKeyLine + key + ": -1\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
key, testImageRequestsPerMinuteVar, "value -1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a number",
|
||||||
|
yaml: signingKeyLine + key + ": plenty\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
key, testImageRequestsPerMinuteVar, "plenty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fractional",
|
||||||
|
yaml: signingKeyLine + key + ": 2.5\n",
|
||||||
|
wantErrSubstrings: []string{key, "2.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit null",
|
||||||
|
yaml: signingKeyLine + key + ": null\n",
|
||||||
|
wantErrSubstrings: []string{key, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRequestsPerMinuteFromEnvironment checks that the variable sets the
|
||||||
|
// limit over the config file, and that an invalid value in it aborts startup
|
||||||
|
// naming the variable and the value.
|
||||||
|
func TestImageRequestsPerMinuteFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv(testImageRequestsPerMinuteVar, "0")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"image_requests_per_minute: 30\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("limit from the environment should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.ImageRequestsPerMinute != 0 {
|
||||||
|
t.Errorf("ImageRequestsPerMinute = %d, want 0 from the environment",
|
||||||
|
c.ImageRequestsPerMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range []string{"-5", "ten"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Setenv(testImageRequestsPerMinuteVar, value)
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, testImageRequestsPerMinuteVar, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRefererBlocklistParsed loads a referer_blocklist with a host and a
|
||||||
|
// pattern starting with "." and checks both are kept in order.
|
||||||
|
func TestRefererBlocklistParsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+`referer_blocklist:
|
||||||
|
- leech.example
|
||||||
|
- .hotlinker.example
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid referer_blocklist should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"leech.example", ".hotlinker.example"}
|
||||||
|
if !slices.Equal(c.RefererBlocklist, want) {
|
||||||
|
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefererBlocklistAcceptsIPAddresses checks that IPv4 and IPv6 addresses,
|
||||||
|
// the IPv6 one written without brackets, are accepted as entries.
|
||||||
|
func TestRefererBlocklistAcceptsIPAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+`referer_blocklist:
|
||||||
|
- 192.0.2.7
|
||||||
|
- "2001:db8::7"
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IP address entries should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"192.0.2.7", "2001:db8::7"}
|
||||||
|
if !slices.Equal(c.RefererBlocklist, want) {
|
||||||
|
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefererBlocklistAcceptsUnderscore checks that a host name with an
|
||||||
|
// underscore, which a page can be served from, is accepted as an entry.
|
||||||
|
func TestRefererBlocklistAcceptsUnderscore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+`referer_blocklist:
|
||||||
|
- my_site.leech.example
|
||||||
|
- .my_site.hotlinker.example
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("host names with an underscore should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"my_site.leech.example", ".my_site.hotlinker.example"}
|
||||||
|
if !slices.Equal(c.RefererBlocklist, want) {
|
||||||
|
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefererBlocklistOmittedIsEmpty checks that an omitted key blocks no
|
||||||
|
// referer.
|
||||||
|
func TestRefererBlocklistOmittedIsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.RefererBlocklist) != 0 {
|
||||||
|
t.Errorf("RefererBlocklist = %v, want empty", c.RefererBlocklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefererBlocklistInvalidAbortsStartup checks that an entry that is not a
|
||||||
|
// host, or a value that is not a list of them, aborts startup with an error
|
||||||
|
// naming the key and the entry.
|
||||||
|
func TestRefererBlocklistInvalidAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "entry with a scheme",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - https://leech.example\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyRefererBlocklist, "https://leech.example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entry with a path",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - leech.example/page\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyRefererBlocklist, "leech.example/page",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard entry",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - \"*.leech.example\"\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyRefererBlocklist, "*.leech.example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entry with a port",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - leech.example:8080\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyRefererBlocklist, "leech.example:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two leading dots",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - ..leech.example\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyRefererBlocklist, "..leech.example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot only",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - \".\"\n",
|
||||||
|
wantErrSubstrings: []string{keyRefererBlocklist, `"."`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty entry",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - \"\"\n",
|
||||||
|
wantErrSubstrings: []string{keyRefererBlocklist},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entry not a string",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n - 42\n",
|
||||||
|
wantErrSubstrings: []string{keyRefererBlocklist, "42"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null value",
|
||||||
|
yaml: signingKeyLine + "referer_blocklist:\n",
|
||||||
|
wantErrSubstrings: []string{keyRefererBlocklist, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefererBlocklistFromEnvironment checks that PIXA_REFERER_BLOCKLIST
|
||||||
|
// takes comma-separated entries, and that an entry in it that is not a host
|
||||||
|
// aborts startup naming the variable and the entry.
|
||||||
|
func TestRefererBlocklistFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
|
t.Setenv("PIXA_REFERER_BLOCKLIST", " leech.example , .hotlinker.example ")
|
||||||
|
|
||||||
|
c, err := newFromSmartConfig(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid PIXA_REFERER_BLOCKLIST should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"leech.example", ".hotlinker.example"}
|
||||||
|
if !slices.Equal(c.RefererBlocklist, want) {
|
||||||
|
t.Errorf("RefererBlocklist = %v, want %v", c.RefererBlocklist, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_REFERER_BLOCKLIST", "leech.example,https://hotlinker.example")
|
||||||
|
|
||||||
|
_, err = newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_REFERER_BLOCKLIST", "https://hotlinker.example")
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConcurrentWritesAllSucceed opens a database the way pixad does and
|
||||||
|
// writes to it from several goroutines at once, as one request's writes and
|
||||||
|
// the background eviction pass do. Every write must succeed, none failing
|
||||||
|
// with "database is locked", whether or not db_url already has parameters,
|
||||||
|
// and the parameters it has must still apply.
|
||||||
|
func TestConcurrentWritesAllSucceed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantJournalMode string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "db_url without parameters",
|
||||||
|
query: "",
|
||||||
|
wantJournalMode: "delete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "db_url with the WAL parameter",
|
||||||
|
query: "?_pragma=journal_mode(WAL)",
|
||||||
|
wantJournalMode: "wal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dbURL := "file:" + filepath.Join(t.TempDir(), "state.sqlite3") + tt.query
|
||||||
|
|
||||||
|
d := &Database{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
config: &config.Config{DBURL: dbURL},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := d.connect(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to connect to %q: %v", dbURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = d.db.Close() })
|
||||||
|
|
||||||
|
writeConcurrently(t, d.db)
|
||||||
|
|
||||||
|
var journalMode string
|
||||||
|
|
||||||
|
err = d.db.QueryRowContext(t.Context(), "PRAGMA journal_mode").
|
||||||
|
Scan(&journalMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read the journal mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalMode != tt.wantJournalMode {
|
||||||
|
t.Errorf("journal mode = %q, want %q", journalMode, tt.wantJournalMode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConcurrently runs writeLikeOneRequest from several goroutines at once
|
||||||
|
// and checks that every write was made.
|
||||||
|
func writeConcurrently(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const (
|
||||||
|
writers = 4
|
||||||
|
requestsEach = 20
|
||||||
|
totalRequests = writers * requestsEach
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for writer := range writers {
|
||||||
|
wg.Go(func() {
|
||||||
|
for request := range requestsEach {
|
||||||
|
key := fmt.Sprintf("%d-%d", writer, request)
|
||||||
|
|
||||||
|
err := writeLikeOneRequest(ctx, db, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("writer %d: %v", writer, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
var hits, sources int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
|
||||||
|
FROM cache_stats WHERE id = 1
|
||||||
|
`).Scan(&hits, &sources)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count the writes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hits != totalRequests || sources != totalRequests {
|
||||||
|
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
|
||||||
|
hits, sources, totalRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLikeOneRequest makes the writes one request and the eviction pass
|
||||||
|
// make: it counts a cache hit, stores a source, records a transformed image
|
||||||
|
// and deletes that record again.
|
||||||
|
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting a cache hit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
|
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
||||||
|
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recording a transformed image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("evicting a transformed image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -237,13 +237,24 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying sql.DB.
|
// DB returns the underlying sql.DB. It has one connection, so close any
|
||||||
|
// rows and end any transaction before running another query on it; a
|
||||||
|
// query run while they are open waits forever.
|
||||||
func (s *Database) DB() *sql.DB {
|
func (s *Database) DB() *sql.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) connect(ctx context.Context) error {
|
func (s *Database) connect(ctx context.Context) error {
|
||||||
dbURL := s.config.DBURL
|
// With a busy timeout, a write that finds another program writing to
|
||||||
|
// the same database file waits up to five seconds for it instead of
|
||||||
|
// failing at once with "database is locked". The driver runs each
|
||||||
|
// _pragma parameter on every connection it opens.
|
||||||
|
separator := "?"
|
||||||
|
if strings.Contains(s.config.DBURL, "?") {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
|
||||||
|
dbURL := s.config.DBURL + separator + "_pragma=busy_timeout(5000)"
|
||||||
|
|
||||||
s.log.Info("connecting to database", "url", dbURL)
|
s.log.Info("connecting to database", "url", dbURL)
|
||||||
|
|
||||||
@@ -254,6 +265,11 @@ func (s *Database) connect(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One connection: pixa's own reads and writes run on it one at a
|
||||||
|
// time instead of competing for SQLite's lock, where a write that
|
||||||
|
// keeps losing can wait past the busy timeout and be lost.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
err = db.PingContext(ctx)
|
err = db.PingContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to ping database", "error", err)
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
|
|
||||||
-- Source content blobs
|
-- Source content blobs
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
||||||
-- last_accessed_at is NULL until the first LRU touch; eviction falls
|
|
||||||
-- back to fetched_at for rows that have never been touched.
|
|
||||||
CREATE TABLE IF NOT EXISTS source_content (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
content_hash TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_accessed_at DATETIME
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
ON source_content(last_accessed_at);
|
ON source_content(last_accessed_at);
|
||||||
@@ -42,9 +40,8 @@ CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(conte
|
|||||||
-- Processed variant blobs
|
-- Processed variant blobs
|
||||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
||||||
-- sidecar with the content type). Tracked here (like source content
|
-- sidecar with the content type). Tracked here (like source content
|
||||||
-- blobs above) so total cache usage can be computed with a SUM query,
|
-- blobs above) so total cache usage is known without a directory scan,
|
||||||
-- never a directory scan, and so LRU eviction has a timestamp to order
|
-- and so LRU eviction has a timestamp to order on.
|
||||||
-- on.
|
|
||||||
CREATE TABLE IF NOT EXISTS variant_content (
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
cache_key TEXT PRIMARY KEY,
|
cache_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
@@ -55,6 +52,74 @@ CREATE TABLE IF NOT EXISTS variant_content (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
ON variant_content(last_accessed_at);
|
ON variant_content(last_accessed_at);
|
||||||
|
|
||||||
|
-- Total cache usage: the sum of size_bytes over source_content and
|
||||||
|
-- variant_content, kept by the triggers below in the same statement
|
||||||
|
-- that adds, removes or resizes a row, so eviction reads this one row
|
||||||
|
-- instead of summing both tables. Each trigger also adds one to
|
||||||
|
-- change_count; the reconciliation pass, which sums both tables a page
|
||||||
|
-- at a time, corrects total_size_bytes only if change_count did not
|
||||||
|
-- move while it summed.
|
||||||
|
CREATE TABLE IF NOT EXISTS cache_usage (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
change_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO cache_usage (id) VALUES (1);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS source_content_usage_insert
|
||||||
|
AFTER INSERT ON source_content
|
||||||
|
BEGIN
|
||||||
|
UPDATE cache_usage
|
||||||
|
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
|
||||||
|
change_count = change_count + 1
|
||||||
|
WHERE id = 1;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS source_content_usage_delete
|
||||||
|
AFTER DELETE ON source_content
|
||||||
|
BEGIN
|
||||||
|
UPDATE cache_usage
|
||||||
|
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
|
||||||
|
change_count = change_count + 1
|
||||||
|
WHERE id = 1;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS source_content_usage_update
|
||||||
|
AFTER UPDATE OF size_bytes ON source_content
|
||||||
|
BEGIN
|
||||||
|
UPDATE cache_usage
|
||||||
|
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes,
|
||||||
|
change_count = change_count + 1
|
||||||
|
WHERE id = 1;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS variant_content_usage_insert
|
||||||
|
AFTER INSERT ON variant_content
|
||||||
|
BEGIN
|
||||||
|
UPDATE cache_usage
|
||||||
|
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
|
||||||
|
change_count = change_count + 1
|
||||||
|
WHERE id = 1;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS variant_content_usage_delete
|
||||||
|
AFTER DELETE ON variant_content
|
||||||
|
BEGIN
|
||||||
|
UPDATE cache_usage
|
||||||
|
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
|
||||||
|
change_count = change_count + 1
|
||||||
|
WHERE id = 1;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS variant_content_usage_update
|
||||||
|
AFTER UPDATE OF size_bytes ON variant_content
|
||||||
|
BEGIN
|
||||||
|
UPDATE cache_usage
|
||||||
|
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes,
|
||||||
|
change_count = change_count + 1
|
||||||
|
WHERE id = 1;
|
||||||
|
END;
|
||||||
|
|
||||||
-- Output/transformed content blobs
|
-- Output/transformed content blobs
|
||||||
-- Not written: transformed images are stored in cache/variants and
|
-- Not written: transformed images are stored in cache/variants and
|
||||||
-- tracked in variant_content above.
|
-- tracked in variant_content above.
|
||||||
@@ -111,3 +176,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
|
||||||
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// Default values for optional fields.
|
// Default values for optional fields.
|
||||||
const (
|
const (
|
||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imgcache.FormatOriginal
|
DefaultFormat = imgcache.FormatJXL
|
||||||
DefaultFitMode = imgcache.FitCover
|
DefaultFitMode = imgcache.FitCover
|
||||||
|
|
||||||
// HKDF salt for URL encryption key derivation
|
// HKDF salt for URL encryption key derivation
|
||||||
@@ -37,7 +37,7 @@ type Payload struct {
|
|||||||
SourceQuery string `cbor:"q,omitempty"` // optional
|
SourceQuery string `cbor:"q,omitempty"` // optional
|
||||||
Width int `cbor:"w,omitempty"` // 0 = original
|
Width int `cbor:"w,omitempty"` // 0 = original
|
||||||
Height int `cbor:"ht,omitempty"` // 0 = original
|
Height int `cbor:"ht,omitempty"` // 0 = original
|
||||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig
|
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
|
||||||
Quality int `cbor:"ql,omitempty"` // default: 85
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
||||||
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
||||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
const appname = "pixad"
|
const appname = "pixad"
|
||||||
|
|
||||||
// Version is populated from main() via ldflags.
|
// Version is set by app.Run to the version main was built with.
|
||||||
var Version string //nolint:gochecknoglobals // set from main
|
var Version string //nolint:gochecknoglobals // set by app.Run
|
||||||
|
|
||||||
// Globals holds application-wide constants.
|
// Globals holds application-wide constants.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
|
|||||||
@@ -89,10 +89,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,10 +377,15 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri
|
|||||||
scheme = "http"
|
scheme = "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine file extension for the trailing filename
|
// Determine file extension for the trailing filename. A form with no
|
||||||
|
// format makes a token with none, which is served as encurl.DefaultFormat.
|
||||||
ext := format
|
ext := format
|
||||||
if ext == "" || ext == "orig" {
|
|
||||||
ext = "jpg" // Default extension
|
switch format {
|
||||||
|
case "":
|
||||||
|
ext = string(encurl.DefaultFormat)
|
||||||
|
case "orig", "auto":
|
||||||
|
ext = "jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoginLogLeavesOutSubmittedKey verifies that the log lines for a
|
||||||
|
// failed and for a successful login do not contain the submitted key.
|
||||||
|
func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const wrongKey = "wrong-signing-key-fedcba9876543210"
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||||
|
config: &config.Config{SigningKey: testSigningKey},
|
||||||
|
sessMgr: sessMgr,
|
||||||
|
}
|
||||||
|
|
||||||
|
submittedKeys := []string{wrongKey, testSigningKey}
|
||||||
|
|
||||||
|
for _, key := range submittedKeys {
|
||||||
|
form := url.Values{loginKeyField: {key}}
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
h.handleLoginPost(httptest.NewRecorder(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range []string{"failed login attempt", "successful login"} {
|
||||||
|
if !strings.Contains(buf.String(), msg) {
|
||||||
|
t.Fatalf("log missing %q; got %q", msg, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range submittedKeys {
|
||||||
|
if strings.Contains(buf.String(), key) {
|
||||||
|
t.Errorf("log contains submitted key %q; got %q", key, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatField is the generator form's format field name.
|
||||||
|
const formatField = "format"
|
||||||
|
|
||||||
|
// Markers telling the login page from the generator page.
|
||||||
|
const (
|
||||||
|
loginForm = `action="/"`
|
||||||
|
loginKeyInput = `name="key"`
|
||||||
|
generatorForm = `action="/generate"`
|
||||||
|
)
|
||||||
|
|
||||||
|
// generatedURLPattern extracts the path of the URL the generator page shows.
|
||||||
|
// The test router runs with debug on, so the URL starts with http, and its
|
||||||
|
// host is httptest's default request host.
|
||||||
|
var generatedURLPattern = regexp.MustCompile(
|
||||||
|
`value="http://example\.com(/v1/e/[^"]+)"`)
|
||||||
|
|
||||||
|
// findSessionCookie returns the session cookie rec sets, or nil if it sets
|
||||||
|
// none.
|
||||||
|
func findSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleRoot_NoSession_ShowsLoginForm verifies that GET / without a
|
||||||
|
// login session shows the login form.
|
||||||
|
func TestHandleRoot_NoSession_ShowsLoginForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
|
||||||
|
t.Errorf("page is not the login form: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPost_WrongKey_ShowsErrorWithoutSession verifies that a wrong key
|
||||||
|
// shows the login form again with an error, and sets no session cookie.
|
||||||
|
func TestLoginPost_WrongKey_ShowsErrorWithoutSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {"wrong-signing-key-fedcba9876543210"},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
|
||||||
|
t.Errorf("page is not the login form: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, "Invalid signing key") {
|
||||||
|
t.Error("login form does not show the error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := findSessionCookie(rec); c != nil {
|
||||||
|
t.Errorf("wrong key set a session cookie: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPost_RightKey_SetsSessionCookie verifies that the right key answers
|
||||||
|
// 303 to / with a session cookie marked Secure, HttpOnly and SameSite=Strict,
|
||||||
|
// and that GET / with that cookie shows the generator page.
|
||||||
|
func TestLoginPost_RightKey_SetsSessionCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||||
|
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCookie := findSessionCookie(rec)
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatal("right key set no session cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Set-Cookie: %s", sessionCookie)
|
||||||
|
|
||||||
|
if !sessionCookie.Secure {
|
||||||
|
t.Error("session cookie is not Secure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sessionCookie.HttpOnly {
|
||||||
|
t.Error("session cookie is not HttpOnly")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Errorf("session cookie SameSite = %v, want Strict", sessionCookie.SameSite)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(sessionCookie)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(rec.Body.String(), generatorForm) {
|
||||||
|
t.Errorf("GET / with the session cookie: status = %d, "+
|
||||||
|
"want %d and the generator page", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleLogout_ClearsSessionCookie verifies that GET /logout answers 303
|
||||||
|
// to / and replaces the session cookie with an empty one sent with
|
||||||
|
// Max-Age=0, which makes the browser delete it.
|
||||||
|
func TestHandleLogout_ClearsSessionCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, _ := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/logout", nil)
|
||||||
|
req.AddCookie(newSessionCookie(t, h))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleLogout().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||||
|
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Set-Cookie: %s", rec.Header().Get("Set-Cookie"))
|
||||||
|
|
||||||
|
sessionCookie := findSessionCookie(rec)
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatal("logout did not set the session cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie.Value != "" {
|
||||||
|
t.Errorf("session cookie value = %q, want empty", sessionCookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// net/http reads a Max-Age=0 attribute back as MaxAge -1.
|
||||||
|
if sessionCookie.MaxAge != -1 {
|
||||||
|
t.Errorf("session cookie MaxAge = %d, want -1 (Max-Age=0)",
|
||||||
|
sessionCookie.MaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleLogout_EndsCopiedSession verifies that after POST /logout a copy
|
||||||
|
// of the session cookie taken before it shows the login page, not the
|
||||||
|
// generator page.
|
||||||
|
func TestHandleLogout_EndsCopiedSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
copied := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/logout", nil)
|
||||||
|
req.AddCookie(copied)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleLogout().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /logout status = %d, want %d",
|
||||||
|
rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(copied)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
t.Logf("GET / with the copied cookie: status %d, login page %v",
|
||||||
|
rec.Code, strings.Contains(body, loginForm))
|
||||||
|
|
||||||
|
if !strings.Contains(body, loginForm) || strings.Contains(body, generatorForm) {
|
||||||
|
t.Error("a cookie copied before /logout still opens the generator page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleLogout_SaveFails_Answers500 verifies that when the logout cannot
|
||||||
|
// be saved in the database, POST /logout answers 500, leaves the session
|
||||||
|
// cookie in place and ends no session.
|
||||||
|
func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManagerWithDatabase(
|
||||||
|
t.Context(), testSigningKey, db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sessMgr = sessMgr
|
||||||
|
cookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
// Saving the logout fails on a closed database.
|
||||||
|
err = db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("closing the database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/logout", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleLogout().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
t.Logf("POST /logout: status %d, Set-Cookie %q",
|
||||||
|
rec.Code, rec.Header().Get("Set-Cookie"))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("POST /logout status = %d, want %d",
|
||||||
|
rec.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := findSessionCookie(rec); c != nil {
|
||||||
|
t.Errorf("a logout that failed set the session cookie: %s", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), generatorForm) {
|
||||||
|
t.Error("a logout that failed ended the session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
||||||
|
// with a valid CSRF token but no login session answers 303 to / and makes no
|
||||||
|
// URL.
|
||||||
|
func TestGeneratePost_NoSession_RedirectsToLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||||
|
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a URL was made without a login session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_URLServesImage verifies that the URL the generator page
|
||||||
|
// makes is served by /v1/e/. The image route runs on handlers of its own,
|
||||||
|
// made with the same signing key.
|
||||||
|
func TestGeneratePost_URLServesImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{
|
||||||
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
|
widthField: {"50"},
|
||||||
|
heightField: {"50"},
|
||||||
|
formatField: {string(imgcache.FormatJPEG)},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("generated URL path: %s", match[1])
|
||||||
|
|
||||||
|
imageRec := httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
requireServedPhoto(t, imageRec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_URLWithTTLExpires verifies that a URL the generator page
|
||||||
|
// makes with a ttl of one second is served by /v1/e/ at once and answers 410
|
||||||
|
// once the ttl has passed.
|
||||||
|
func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{
|
||||||
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
|
widthField: {"50"},
|
||||||
|
heightField: {"50"},
|
||||||
|
formatField: {string(imgcache.FormatJPEG)},
|
||||||
|
ttlField: {"1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
imageRec := httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
requireServedPhoto(t, imageRec)
|
||||||
|
|
||||||
|
// The URL keeps the time it expires in whole seconds and is served
|
||||||
|
// through the whole of that second, so a ttl of one second has passed
|
||||||
|
// for certain two seconds after the URL was made.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
imageRec = httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
t.Logf("GET %s after the ttl: %d %q", match[1], imageRec.Code, imageRec.Body)
|
||||||
|
|
||||||
|
if imageRec.Code != http.StatusGone {
|
||||||
|
t.Errorf("status after the ttl = %d, want %d",
|
||||||
|
imageRec.Code, http.StatusGone)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and
|
||||||
|
// handleImageError, which answer the errors of the two image routes, answer
|
||||||
|
// 400 naming the AVIF pixel limit when the image processor refuses an AVIF
|
||||||
|
// output above it, wrapped as the service wraps it. The request's source only
|
||||||
|
// goes into a log line, so it is left empty.
|
||||||
|
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
err := fmt.Errorf("image processing failed: %w: 4097x4096",
|
||||||
|
imageprocessor.ErrAVIFOutputTooLarge)
|
||||||
|
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
||||||
|
|
||||||
|
plain := httptest.NewRecorder()
|
||||||
|
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"respondImageError": plain, "handleImageError": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: status = %d, want %d",
|
||||||
|
name, rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), wantBody) {
|
||||||
|
t.Errorf("%s: body = %q, want it to say %q",
|
||||||
|
name, rec.Body.String(), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
|
||||||
|
// built from cache_max_bytes: omitted, the cache works out the default
|
||||||
|
// limit; 0 turns the disk cache off; a positive value is the limit,
|
||||||
|
// unchanged.
|
||||||
|
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const oneGiB = 1 << 30
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cacheMaxBytes int64
|
||||||
|
cacheMaxBytesExplicit bool
|
||||||
|
wantMaxBytes int64
|
||||||
|
wantUseDefaultMaxBytes bool
|
||||||
|
wantDisableDiskCache bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cache_max_bytes omitted",
|
||||||
|
cacheMaxBytes: 0,
|
||||||
|
cacheMaxBytesExplicit: false,
|
||||||
|
wantMaxBytes: 0,
|
||||||
|
wantUseDefaultMaxBytes: true,
|
||||||
|
wantDisableDiskCache: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cache_max_bytes: 0",
|
||||||
|
cacheMaxBytes: 0,
|
||||||
|
cacheMaxBytesExplicit: true,
|
||||||
|
wantMaxBytes: 0,
|
||||||
|
wantUseDefaultMaxBytes: false,
|
||||||
|
wantDisableDiskCache: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cache_max_bytes: 1 GiB",
|
||||||
|
cacheMaxBytes: oneGiB,
|
||||||
|
cacheMaxBytesExplicit: true,
|
||||||
|
wantMaxBytes: oneGiB,
|
||||||
|
wantUseDefaultMaxBytes: false,
|
||||||
|
wantDisableDiskCache: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
CacheMaxBytes: tc.cacheMaxBytes,
|
||||||
|
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := newCacheConfig(cfg, nil)
|
||||||
|
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
|
||||||
|
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
|
||||||
|
|
||||||
|
if got.MaxBytes != tc.wantMaxBytes {
|
||||||
|
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
|
||||||
|
t.Errorf("UseDefaultMaxBytes = %v, want %v",
|
||||||
|
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.DisableDiskCache != tc.wantDisableDiskCache {
|
||||||
|
t.Errorf("DisableDiskCache = %v, want %v",
|
||||||
|
got.DisableDiskCache, tc.wantDisableDiskCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
|
||||||
|
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
|
||||||
|
// and checks by whether the cache directories were created that the
|
||||||
|
// disk cache is on in the first case and off in the second.
|
||||||
|
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cacheMaxBytesExplicit bool
|
||||||
|
wantDiskCache bool
|
||||||
|
}{
|
||||||
|
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
|
||||||
|
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
StateDir: stateDir,
|
||||||
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
|
CacheMaxBytes: 0,
|
||||||
|
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("database.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
t.Cleanup(lc.RequireStop)
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
|
||||||
|
gotDiskCache := err == nil
|
||||||
|
|
||||||
|
if gotDiskCache != tc.wantDiskCache {
|
||||||
|
t.Errorf("cache directories created = %v, want %v",
|
||||||
|
gotDiskCache, tc.wantDiskCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided builds the handlers as
|
||||||
|
// pixad does, in an fx app that provides no fetcher, and requests an image
|
||||||
|
// from 192.0.2.10, which is on the allowlist and in blocked_networks. The URL
|
||||||
|
// check accepts that address; only the dialer that refuses internal
|
||||||
|
// addresses checks blocked_networks, so the answer is 403 only if the
|
||||||
|
// fetcher the handlers build from the config connects with that dialer. Any
|
||||||
|
// other dialer would try to connect until the upstream fetch timeout, which
|
||||||
|
// is short so that the test then fails quickly.
|
||||||
|
func TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "192.0.2.10"
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
StateDir: stateDir,
|
||||||
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
|
AllowlistHosts: []string{host},
|
||||||
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
|
UpstreamFetchTimeout: 2 * time.Second,
|
||||||
|
// With no connection slots, the fetch would fail before dialing.
|
||||||
|
UpstreamConnections: config.DefaultUpstreamConnections,
|
||||||
|
}
|
||||||
|
|
||||||
|
var h *Handlers
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Supply(cfg),
|
||||||
|
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New),
|
||||||
|
fx.Populate(&h),
|
||||||
|
)
|
||||||
|
app.RequireStart()
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
|
||||||
|
rec := sendGet(t, r, photoURL(host))
|
||||||
|
checkErrorBody(t, rec, http.StatusForbidden, "forbidden")
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors for an Accept header that an auto URL cannot be served for.
|
||||||
|
var (
|
||||||
|
errInvalidAccept = errors.New("invalid Accept header")
|
||||||
|
errNotAcceptable = errors.New("not acceptable: auto serves " +
|
||||||
|
"image/jxl, image/avif, image/webp or image/jpeg")
|
||||||
|
)
|
||||||
|
|
||||||
|
// chooseAutoFormat replaces the format auto in req with the format
|
||||||
|
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
|
||||||
|
// response, which then depends on that header. It answers 400 for an Accept
|
||||||
|
// header that is not valid and 406 for one that allows none of the formats,
|
||||||
|
// and reports whether req can be served. Any other format is left as it is.
|
||||||
|
func (s *Handlers) chooseAutoFormat(
|
||||||
|
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest,
|
||||||
|
) bool {
|
||||||
|
if req.Format != imgcache.FormatAuto {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Add("Vary", "Accept")
|
||||||
|
|
||||||
|
format, err := formatForAccept(strings.Join(r.Header.Values("Accept"), ","))
|
||||||
|
if errors.Is(err, errNotAcceptable) {
|
||||||
|
s.respondError(w, err.Error(), http.StatusNotAcceptable)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Format = format
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatForAccept returns the format an auto URL is served in for the Accept
|
||||||
|
// header accept: JPEG XL when it names image/jxl, else AVIF when it names
|
||||||
|
// image/avif, else WebP when it names image/webp, else JPEG when its most
|
||||||
|
// specific entry of image/jpeg, image/* and */* allows it, or when it names
|
||||||
|
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
|
||||||
|
// clients that cannot show them send image/* and */* too.
|
||||||
|
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
||||||
|
qualities, err := parseAccept(accept)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(qualities) == 0 {
|
||||||
|
return imgcache.FormatJPEG, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualities["image/jxl"] > 0 {
|
||||||
|
return imgcache.FormatJXL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualities["image/avif"] > 0 {
|
||||||
|
return imgcache.FormatAVIF, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualities["image/webp"] > 0 {
|
||||||
|
return imgcache.FormatWebP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For JPEG, the most specific entry the header has decides
|
||||||
|
quality, named := qualities["image/jpeg"]
|
||||||
|
if !named {
|
||||||
|
quality, named = qualities["image/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !named {
|
||||||
|
quality = qualities["*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if quality > 0 {
|
||||||
|
return imgcache.FormatJPEG, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errNotAcceptable
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAccept returns the q of each media range the Accept header accept
|
||||||
|
// names, 1 where it gives none. A media range named more than once keeps its
|
||||||
|
// lowest q, so a refusal is never overridden. A media range that does not
|
||||||
|
// parse, or a q that is not a number from 0 to 1, is an error.
|
||||||
|
func parseAccept(accept string) (map[string]float64, error) {
|
||||||
|
qualities := make(map[string]float64)
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(accept, ",") {
|
||||||
|
// A header field list may hold empty entries
|
||||||
|
if strings.TrimSpace(entry) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRange, params, err := mime.ParseMediaType(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %q: %w", errInvalidAccept, entry, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
quality := 1.0
|
||||||
|
|
||||||
|
if qParam, given := params["q"]; given {
|
||||||
|
quality, err = strconv.ParseFloat(qParam, 64)
|
||||||
|
inRange := quality >= 0 && quality <= 1
|
||||||
|
|
||||||
|
if err != nil || !inRange {
|
||||||
|
return nil, fmt.Errorf("%w: %q: q is not a number from 0 to 1",
|
||||||
|
errInvalidAccept, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previous, named := qualities[mediaRange]
|
||||||
|
if !named || quality < previous {
|
||||||
|
qualities[mediaRange] = quality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return qualities, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The content types the tests below expect.
|
||||||
|
const (
|
||||||
|
avifType = "image/avif"
|
||||||
|
webpType = "image/webp"
|
||||||
|
jpegType = "image/jpeg"
|
||||||
|
jsonType = "application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFormatForAccept verifies the format chosen for the format auto from each
|
||||||
|
// Accept header below, and the error for one that allows none of AVIF, WebP
|
||||||
|
// and JPEG or is not valid.
|
||||||
|
func TestFormatForAccept(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accept string
|
||||||
|
want imgcache.ImageFormat
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"AVIF-capable browser",
|
||||||
|
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
|
imgcache.FormatAVIF, nil},
|
||||||
|
{"WebP-capable browser",
|
||||||
|
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
||||||
|
imgcache.FormatWebP, nil},
|
||||||
|
{"WebP only", webpType, imgcache.FormatWebP, nil},
|
||||||
|
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
|
||||||
|
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
|
||||||
|
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
|
||||||
|
{"absent", "", imgcache.FormatJPEG, nil},
|
||||||
|
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
|
||||||
|
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
||||||
|
imgcache.FormatJPEG, nil},
|
||||||
|
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
|
||||||
|
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
|
||||||
|
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
|
||||||
|
{"PNG only", "image/png", "", errNotAcceptable},
|
||||||
|
{"malformed media range", "image/", "", errInvalidAccept},
|
||||||
|
{"q not a number", "image/avif;q=high", "", errInvalidAccept},
|
||||||
|
{"q above 1", "image/avif;q=2", "", errInvalidAccept},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := formatForAccept(tt.accept)
|
||||||
|
t.Logf("Accept %q: %q, %v", tt.accept, got, err)
|
||||||
|
|
||||||
|
if got != tt.want || !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("formatForAccept(%q) = %q, %v, want %q, %v",
|
||||||
|
tt.accept, got, err, tt.want, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
|
||||||
|
// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in
|
||||||
|
// the format auto, made with h's image service and generator.
|
||||||
|
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatAuto,
|
||||||
|
}, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: imgcache.FormatAuto,
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signedURL, "/v1/e/" + token + "/img.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestImage sends method for target to srv with an Accept header line for
|
||||||
|
// each of accept, and returns the response.
|
||||||
|
func requestImage(
|
||||||
|
t *testing.T, srv http.Handler, method, target string, accept ...string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
|
||||||
|
|
||||||
|
for _, value := range accept {
|
||||||
|
req.Header.Add("Accept", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
t.Logf("%s %s with Accept %q: %d, Content-Type %s, Vary %v, X-Pixa-Cache %s",
|
||||||
|
method, target, accept, rec.Code, rec.Header().Get("Content-Type"),
|
||||||
|
rec.Header().Values("Vary"), rec.Header().Get("X-Pixa-Cache"))
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuto_ChosenFromAccept requests an auto URL on each image route
|
||||||
|
// with each Accept below, and checks the answer and that it carries
|
||||||
|
// Vary: Accept. The signed URL is signed for auto, so it is valid whatever
|
||||||
|
// Accept chooses.
|
||||||
|
func TestFormatAuto_ChosenFromAccept(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
signedURL, encryptedURL := autoPhotoURLs(t, h)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accept []string
|
||||||
|
wantStatus int
|
||||||
|
wantType string
|
||||||
|
}{
|
||||||
|
{"AVIF accepted", []string{"image/avif,image/webp,*/*;q=0.8"},
|
||||||
|
http.StatusOK, avifType},
|
||||||
|
{"WebP accepted", []string{"image/webp,*/*;q=0.8"}, http.StatusOK, webpType},
|
||||||
|
{"no Accept", nil, http.StatusOK, jpegType},
|
||||||
|
{"two Accept lines", []string{"image/png", webpType}, http.StatusOK, webpType},
|
||||||
|
{"none of the three", []string{"image/gif"},
|
||||||
|
http.StatusNotAcceptable, jsonType},
|
||||||
|
{"not valid", []string{"image/avif;q=high"}, http.StatusBadRequest, jsonType},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, target := range []string{signedURL, encryptedURL} {
|
||||||
|
rec := requestImage(t, srv, http.MethodGet, target, tt.accept...)
|
||||||
|
gotType := rec.Header().Get("Content-Type")
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus || gotType != tt.wantType {
|
||||||
|
t.Errorf("%s: %d %s, want %d %s; body %s", target,
|
||||||
|
rec.Code, gotType, tt.wantStatus, tt.wantType, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(rec.Header().Values("Vary"), "Accept") {
|
||||||
|
t.Errorf("%s: Vary = %v, want Accept in it",
|
||||||
|
target, rec.Header().Values("Vary"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuto_SignatureCoversAuto verifies that a /v1/image/ URL with the
|
||||||
|
// format auto is checked against a signature for auto, not for the format
|
||||||
|
// chosen: a URL signed for avif, with auto put in its path, is refused for a
|
||||||
|
// client whose Accept chooses AVIF.
|
||||||
|
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatAVIF,
|
||||||
|
}, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
|
||||||
|
if target == signedForAVIF {
|
||||||
|
t.Fatalf("no /50x50.avif? in %s", signedForAVIF)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := requestImage(t, srv, http.MethodGet, target, avifType)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuto_CachesEachFormatApart requests an auto URL for AVIF, then
|
||||||
|
// JPEG, then both again. Each format is processed once and then served from
|
||||||
|
// the cache, with an ETag of its own, so a client never gets the other format
|
||||||
|
// from the cache.
|
||||||
|
func TestFormatAuto_CachesEachFormatApart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
signedURL, _ := autoPhotoURLs(t, h)
|
||||||
|
|
||||||
|
steps := []struct {
|
||||||
|
wantType string
|
||||||
|
wantCache string
|
||||||
|
}{
|
||||||
|
{avifType, "MISS"},
|
||||||
|
{jpegType, "MISS"},
|
||||||
|
{avifType, "HIT"},
|
||||||
|
{jpegType, "HIT"},
|
||||||
|
}
|
||||||
|
|
||||||
|
etags := make(map[string]string)
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
rec := requestImage(t, srv, http.MethodGet, signedURL, step.wantType)
|
||||||
|
gotType := rec.Header().Get("Content-Type")
|
||||||
|
gotCache := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || gotType != step.wantType ||
|
||||||
|
gotCache != step.wantCache {
|
||||||
|
t.Fatalf("Accept %s: %d %s %s, want 200 %s %s", step.wantType,
|
||||||
|
rec.Code, gotType, gotCache, step.wantType, step.wantCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag := rec.Header().Get("ETag")
|
||||||
|
if previous, seen := etags[gotType]; seen && previous != etag {
|
||||||
|
t.Errorf("%s ETag changed from %s to %s", gotType, previous, etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
etags[gotType] = etag
|
||||||
|
}
|
||||||
|
|
||||||
|
if etags[avifType] == etags[jpegType] {
|
||||||
|
t.Errorf("AVIF and JPEG have the same ETag %s", etags[avifType])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuto_Vary verifies that on each image route a HEAD answer and a
|
||||||
|
// 304 for an auto URL carry Vary: Accept, and that the answer for a URL with
|
||||||
|
// a fixed format does not.
|
||||||
|
func TestFormatAuto_Vary(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
srv := chi.NewRouter()
|
||||||
|
srv.Get("/v1/image/*", h.HandleImage())
|
||||||
|
srv.Head("/v1/image/*", h.HandleImage())
|
||||||
|
srv.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
srv.Head("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
signedURL, encryptedURL := autoPhotoURLs(t, h)
|
||||||
|
|
||||||
|
for _, urls := range [][2]string{
|
||||||
|
{signedURL, signedPhotoURL(t, h)},
|
||||||
|
{encryptedURL, encPhotoURL(t, h)},
|
||||||
|
} {
|
||||||
|
autoURL, fixedURL := urls[0], urls[1]
|
||||||
|
|
||||||
|
head := requestImage(t, srv, http.MethodHead, autoURL, webpType)
|
||||||
|
checkVaryAccept(t, head, http.StatusOK, true)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
autoURL, nil)
|
||||||
|
req.Header.Set("Accept", webpType)
|
||||||
|
req.Header.Set("If-None-Match", head.Header().Get("ETag"))
|
||||||
|
|
||||||
|
notModified := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(notModified, req)
|
||||||
|
checkVaryAccept(t, notModified, http.StatusNotModified, true)
|
||||||
|
|
||||||
|
fixed := requestImage(t, srv, http.MethodGet, fixedURL)
|
||||||
|
checkVaryAccept(t, fixed, http.StatusOK, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVaryAccept fails the test unless rec answered wantStatus and, as
|
||||||
|
// wantVary says, has or has not Accept in its Vary header.
|
||||||
|
func checkVaryAccept(
|
||||||
|
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantVary bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vary := rec.Header().Values("Vary")
|
||||||
|
t.Logf("status %d, Vary %v", rec.Code, vary)
|
||||||
|
|
||||||
|
if rec.Code != wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(vary, "Accept") != wantVary {
|
||||||
|
t.Errorf("Vary = %v, want Accept in it: %v", vary, wantVary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
@@ -27,6 +29,11 @@ type Params struct {
|
|||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
|
|
||||||
|
// Fetcher, when provided, fetches upstream images in place of the
|
||||||
|
// fetcher the handlers build from the config. Only tests provide one;
|
||||||
|
// pixad does not.
|
||||||
|
Fetcher httpfetcher.Fetcher `optional:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
@@ -35,11 +42,16 @@ type Handlers struct {
|
|||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
db *database.Database
|
db *database.Database
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
fetcher httpfetcher.Fetcher
|
||||||
imgSvc *imgcache.Service
|
imgSvc *imgcache.Service
|
||||||
imgCache *imgcache.Cache
|
imgCache *imgcache.Cache
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
csrfProtect func(http.Handler) http.Handler
|
csrfProtect func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
|
||||||
|
// reports whether a URL's host is on that list.
|
||||||
|
refererBlocklist *allowlist.HostAllowList
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -50,24 +62,31 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := &Handlers{
|
s := &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
csrfProtect: csrfProtect,
|
fetcher: params.Fetcher,
|
||||||
|
csrfProtect: csrfProtect,
|
||||||
|
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
//nolint:contextcheck // the eviction loop outlives OnStart; OnStop cancels it
|
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return s.initImageService()
|
return s.initImageService()
|
||||||
},
|
},
|
||||||
|
// The pending counts are written here, before the database's stop
|
||||||
|
// hook closes it.
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
if s.imgCache == nil {
|
if s.imgCache == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.imgCache.StopEviction(ctx)
|
return errors.Join(
|
||||||
|
s.imgCache.StopEviction(ctx),
|
||||||
|
s.imgCache.StopPendingCountWrites(ctx),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,18 +99,24 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|||||||
return s.imgSvc.WaitForProcessing(ctx)
|
return s.imgSvc.WaitForProcessing(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newCacheConfig builds the image cache's configuration from cfg.
|
||||||
|
// cache_max_bytes: 0 disables the disk cache entirely; any other value
|
||||||
|
// is the eviction limit in bytes; when it is omitted, the cache works
|
||||||
|
// out the default limit itself.
|
||||||
|
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
||||||
|
return imgcache.CacheConfig{
|
||||||
|
StateDir: cfg.StateDir,
|
||||||
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||||
|
MaxBytes: cfg.CacheMaxBytes,
|
||||||
|
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
||||||
|
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
|
||||||
|
Logger: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initImageService initializes the image cache and service.
|
// initImageService initializes the image cache and service.
|
||||||
func (s *Handlers) initImageService() error {
|
func (s *Handlers) initImageService() error {
|
||||||
// Create the cache. cache_max_bytes: 0 disables the disk cache
|
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
|
||||||
// entirely; any other value is the eviction limit in bytes.
|
|
||||||
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
|
||||||
StateDir: s.config.StateDir,
|
|
||||||
CacheTTL: imgcache.DefaultCacheTTL,
|
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
|
||||||
MaxBytes: s.config.CacheMaxBytes,
|
|
||||||
DisableDiskCache: s.config.CacheMaxBytes == 0,
|
|
||||||
Logger: s.log,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -99,9 +124,13 @@ 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 requests could not write by their deadline
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := httpfetcher.DefaultConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
@@ -115,10 +144,12 @@ func (s *Handlers) initImageService() error {
|
|||||||
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
||||||
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||||
|
|
||||||
// Create the service
|
// Create the service. With no fetcher provided, it builds its own from
|
||||||
|
// fetcherCfg.
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
|
Fetcher: s.fetcher,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Allowlist: s.config.AllowlistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
||||||
@@ -133,7 +164,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 {
|
||||||
|
|||||||
+72
-10
@@ -9,7 +9,6 @@ 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"
|
||||||
@@ -18,9 +17,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleImage handles the main image proxy route:
|
// HandleImage handles the main image proxy route:
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
|
||||||
|
// /v1/image/<host>/<path>/<width>x<height>
|
||||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
req, ok := s.parseImageRequest(w, r)
|
req, ok := s.parseImageRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -39,6 +43,11 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The signature covers the format auto, not the format chosen
|
||||||
|
if !s.chooseAutoFormat(w, r, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get cache key for logging
|
// Get cache key for logging
|
||||||
cacheKey := imgcache.CacheKey(req)
|
cacheKey := imgcache.CacheKey(req)
|
||||||
|
|
||||||
@@ -70,20 +79,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 ImageRequest. 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) {
|
) (*imgcache.ImageRequest, 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
|
||||||
|
parsed, err := imgcache.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)
|
||||||
@@ -196,6 +206,16 @@ func parseExpires(query url.Values) (time.Time, error) {
|
|||||||
return time.Unix(exp, 0), nil
|
return time.Unix(exp, 0), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleTooManyRequests answers 429 Too Many Requests with the JSON error
|
||||||
|
// body the image routes send for their other errors. The image routes' rate
|
||||||
|
// limit calls it for a request over the limit.
|
||||||
|
func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.respondError(w, "too many requests, try again later",
|
||||||
|
http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// respondImageError maps image retrieval errors to HTTP responses.
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
func (s *Handlers) respondImageError(
|
func (s *Handlers) respondImageError(
|
||||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
@@ -220,13 +240,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +293,23 @@ func cacheControl(expires time.Time) string {
|
|||||||
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
|
||||||
|
// names a host on referer_blocklist, and reports whether it answered. A request
|
||||||
|
// with no Referer, or one that does not parse as a URL with a host, is not
|
||||||
|
// refused.
|
||||||
|
func (s *Handlers) refuseBlockedReferer(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) bool {
|
||||||
|
referer, err := url.Parse(r.Referer())
|
||||||
|
if err != nil || !s.refererBlocklist.IsAllowed(referer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondError(w, "referer blocked", http.StatusForbidden)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// notModified sets the ETag header to etag and, when the request's
|
// notModified sets the ETag header to etag and, when the request's
|
||||||
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
|
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
|
||||||
// answered. An empty etag sets no header and never answers.
|
// answered. An empty etag sets no header and never answers.
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func newSignedHostServer(
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowlistedHost is the only host on the allowlist of the image route
|
||||||
|
// newImageRoute builds.
|
||||||
|
const allowlistedHost = "allowed.example.com"
|
||||||
|
|
||||||
|
// newImageRoute returns the image route of a Handlers whose service fetches
|
||||||
|
// with fetcher and checks signatures with testSigningKey.
|
||||||
|
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: fetcher,
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
Allowlist: []string{allowlistedHost},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
||||||
|
// each of hosts, and answers any other URL with an upstream error.
|
||||||
|
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
photo := &fstest.MapFile{
|
||||||
|
Data: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}),
|
||||||
|
}
|
||||||
|
|
||||||
|
files := fstest.MapFS{}
|
||||||
|
for _, host := range hosts {
|
||||||
|
files[host+photoPath] = photo
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpfetcher.NewMock(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoURL returns the image route URL of photoPath on host, as a 50x50 JPEG.
|
||||||
|
func photoURL(host string) string {
|
||||||
|
return "/v1/image/" + host + photoPath + "/50x50.jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoURLWithSig returns photoURL(host) with sig and expires as its sig and
|
||||||
|
// exp.
|
||||||
|
func photoURLWithSig(host, sig string, expires time.Time) string {
|
||||||
|
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoSignature returns the signature of photoURL(host) at the default
|
||||||
|
// quality and fit, made with key and expiring at expires.
|
||||||
|
func photoSignature(key, host string, expires time.Time) string {
|
||||||
|
return signature.New(key).Sign(&signature.Request{
|
||||||
|
SourceHost: host,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: string(imgcache.FormatJPEG),
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendGet sends a GET for target to route and returns the response.
|
||||||
|
func sendGet(
|
||||||
|
t *testing.T, route http.Handler, target string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
route.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET %s: %d", target, rec.Code)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkErrorBody checks that rec has status wantStatus and the JSON error body
|
||||||
|
// the image route sends: wantError, wantStatus and the time in RFC 3339.
|
||||||
|
func checkErrorBody(
|
||||||
|
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantError string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if rec.Code != wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Error != wantError || body.Status != wantStatus {
|
||||||
|
t.Errorf("body error and status = %q %d, want %q %d",
|
||||||
|
body.Error, body.Status, wantError, wantStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = time.Parse(time.RFC3339, body.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("body timestamp: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_ErrorAnswers checks the status and the JSON error body the
|
||||||
|
// image route answers each request below with. The JPEG at photoPath exists on
|
||||||
|
// signedHost and on each host below that differs from it, so a request refused
|
||||||
|
// with 401 would otherwise be served.
|
||||||
|
func TestHandleImage_ErrorAnswers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A signature for signedHost must not verify for any of these.
|
||||||
|
parentHost := "example.com"
|
||||||
|
siblingHost := "other.example.com"
|
||||||
|
subdomainHost := "img." + signedHost
|
||||||
|
appendedHost := signedHost + ".example.net"
|
||||||
|
|
||||||
|
photos := newPhotoFetcher(t,
|
||||||
|
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
||||||
|
// The real fetcher refuses localhost before any lookup or connection.
|
||||||
|
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
||||||
|
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
expired := time.Now().Add(-time.Hour)
|
||||||
|
sig := photoSignature(testSigningKey, signedHost, exp)
|
||||||
|
otherKeySig := photoSignature("another-signing-key", signedHost, exp)
|
||||||
|
expiredSig := photoSignature(testSigningKey, signedHost, expired)
|
||||||
|
localhostSig := photoSignature(testSigningKey, "localhost", exp)
|
||||||
|
|
||||||
|
// The error every request refused for its signature gets.
|
||||||
|
const unauthorized = "unauthorized"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fetcher httpfetcher.Fetcher
|
||||||
|
target string
|
||||||
|
wantStatus int
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{"no sig or exp", photos, photoURL(signedHost),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"exp but no sig", photos,
|
||||||
|
fmt.Sprintf("%s?exp=%d", photoURL(signedHost), exp.Unix()),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig made with another key", photos,
|
||||||
|
photoURLWithSig(signedHost, otherKeySig, exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig without its = padding", photos,
|
||||||
|
photoURLWithSig(signedHost, strings.TrimRight(sig, "="), exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig in upper case", photos,
|
||||||
|
photoURLWithSig(signedHost, strings.ToUpper(sig), exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"expired sig", photos, photoURLWithSig(signedHost, expiredSig, expired),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig sent for the parent domain", photos,
|
||||||
|
photoURLWithSig(parentHost, sig, exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig sent for a sibling host", photos,
|
||||||
|
photoURLWithSig(siblingHost, sig, exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig sent for a subdomain", photos,
|
||||||
|
photoURLWithSig(subdomainHost, sig, exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"sig sent with another domain appended", photos,
|
||||||
|
photoURLWithSig(appendedHost, sig, exp),
|
||||||
|
http.StatusUnauthorized, unauthorized},
|
||||||
|
{"unparseable path", photos,
|
||||||
|
"/v1/image/" + allowlistedHost + photoPath + "/big.jpeg",
|
||||||
|
http.StatusBadRequest, "invalid image URL: invalid size format"},
|
||||||
|
{"blocked upstream address", realFetcher,
|
||||||
|
photoURLWithSig("localhost", localhostSig, exp),
|
||||||
|
http.StatusForbidden, "forbidden"},
|
||||||
|
{"upstream error", photos,
|
||||||
|
"/v1/image/" + allowlistedHost + "/images/missing.jpg/50x50.jpeg",
|
||||||
|
http.StatusBadGateway, "upstream error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := sendGet(t, newImageRoute(t, tt.fetcher), tt.target)
|
||||||
|
checkErrorBody(t, rec, tt.wantStatus, tt.wantError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_AllowlistOrSignature checks that the image route serves an
|
||||||
|
// image without a signature for a host on the allowlist only, and for another
|
||||||
|
// host only with a valid signature.
|
||||||
|
func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
sig := photoSignature(testSigningKey, signedHost, exp)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"allowlisted host, no sig", photoURL(allowlistedHost), http.StatusOK},
|
||||||
|
{"other host, no sig", photoURL(signedHost), http.StatusUnauthorized},
|
||||||
|
{"other host, valid sig", photoURLWithSig(signedHost, sig, exp),
|
||||||
|
http.StatusOK},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,3 +169,166 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
||||||
|
// by the service's signed-URL generator for source URLs with a query to the
|
||||||
|
// route, through a chi router: chi routes on the path as the client wrote it
|
||||||
|
// whenever that is not Go's own encoding of it. A 502 means the signature
|
||||||
|
// passed, so the route read the source path and query that were signed.
|
||||||
|
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{"slash and colon", "url=https://origin.example.com/cat.jpg"},
|
||||||
|
{"semicolon", "a=1;b=2"},
|
||||||
|
{"comma", "crop=0,0,50,50"},
|
||||||
|
{"equals and ampersand", "a=1&b=2"},
|
||||||
|
{"percent-escape", "name=my%20cat"},
|
||||||
|
{"question mark", "next=/a?b=1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
SourceQuery: tt.query,
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getImage(t, fix, signedURL)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SourceQueryEncodedAnyWay signs one source URL with a query
|
||||||
|
// and writes it into the image URL in ways the README allows: the "?" before
|
||||||
|
// the query and each "%" in it percent-encoded, and every other character as
|
||||||
|
// itself or percent-encoded. A 502 means the signature passed.
|
||||||
|
func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
|
sig := signer.Sign(&signature.Request{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: string(imgcache.FormatJPEG),
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writtenQuery string
|
||||||
|
}{
|
||||||
|
{"only ? and % encoded",
|
||||||
|
"%3Furl=https://origin.example.com/my%2520cat.jpg"},
|
||||||
|
{"every reserved character encoded",
|
||||||
|
"%3Furl%3Dhttps%3A%2F%2Forigin.example.com%2Fmy%2520cat.jpg"},
|
||||||
|
{"lower-case hex digits",
|
||||||
|
"%3furl=https://origin.example.com/my%2520cat.jpg"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
target := fmt.Sprintf("/v1/image/%s%s%s/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, photoPath, tt.writtenQuery, sig, expires.Unix())
|
||||||
|
|
||||||
|
status := getImage(t, setupTestHandler(t), target)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
||||||
|
// source query that each hold %25, the percent-escape of a literal "%", and
|
||||||
|
// sends each to the route written as the README says, with that "%" written as
|
||||||
|
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed.
|
||||||
|
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sourcePath string
|
||||||
|
sourceQuery string
|
||||||
|
// writtenPathAndQuery is the source path and query as the README says
|
||||||
|
// to write them into the image URL
|
||||||
|
writtenPathAndQuery string
|
||||||
|
}{
|
||||||
|
{"path", "/sale/50%25off.jpg", "", "/sale/50%2525off.jpg"},
|
||||||
|
{"query", photoPath, "discount=50%25", photoPath + "%3Fdiscount=50%2525"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+" written by the README rule", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
|
sig := signer.Sign(&signature.Request{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: tt.sourcePath,
|
||||||
|
SourceQuery: tt.sourceQuery,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: string(imgcache.FormatJPEG),
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
|
||||||
|
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
||||||
|
|
||||||
|
status := getImage(t, setupTestHandler(t), target)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: tt.sourcePath,
|
||||||
|
SourceQuery: tt.sourceQuery,
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getImage(t, fix, signedURL)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ import (
|
|||||||
// browsers identify the content type.
|
// browsers identify the content type.
|
||||||
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
req, ok := s.parseImageEncRequest(w, r)
|
req, ok := s.parseImageEncRequest(w, r)
|
||||||
if !ok {
|
if !ok || !s.chooseAutoFormat(w, r, req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +158,22 @@ 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):
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/jpeg"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
||||||
|
// on signedHost at the 50x50 that encPhotoURL and the generator tests ask for.
|
||||||
|
func requireServedPhoto(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body %q",
|
||||||
|
rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := rec.Header().Get("Content-Type")
|
||||||
|
if contentType != "image/jpeg" {
|
||||||
|
t.Errorf("Content-Type = %q, want image/jpeg", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := jpeg.DecodeConfig(rec.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("body is not a JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if img.Width != 50 || img.Height != 50 {
|
||||||
|
t.Errorf("image is %dx%d, want 50x50", img.Width, img.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_ValidToken_ServesImage verifies that a token made with
|
||||||
|
// the signing key serves the image it asks for.
|
||||||
|
func TestHandleImageEnc_ValidToken_ServesImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, encPhotoURL(t, h), nil))
|
||||||
|
|
||||||
|
requireServedPhoto(t, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_RejectedToken verifies that a token that has expired
|
||||||
|
// answers 410, and that a token with one character changed, a token cut
|
||||||
|
// short, and a token made with another signing key answer 400. The server
|
||||||
|
// would serve the photo for a token it accepted.
|
||||||
|
func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
photo := encurl.Payload{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := h.encGen.Generate(&photo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiredPhoto := photo
|
||||||
|
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
|
||||||
|
|
||||||
|
expired, err := h.encGen.Generate(&expiredPhoto)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherKey, err := otherGen.Generate(&photo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a character in the middle always changes the decoded bytes;
|
||||||
|
// the last character of unpadded base64 can carry unused bits.
|
||||||
|
middle := len(valid) / 2
|
||||||
|
|
||||||
|
replacement := "A"
|
||||||
|
if valid[middle] == 'A' {
|
||||||
|
replacement = "B"
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := valid[:middle] + replacement + valid[middle+1:]
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"expired", expired, http.StatusGone},
|
||||||
|
{"one character changed", changed, http.StatusBadRequest},
|
||||||
|
{"cut short", valid[:middle], http.StatusBadRequest},
|
||||||
|
{"another signing key", otherKey, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := getEncToken(srv, tt.token)
|
||||||
|
t.Logf("GET /v1/e/%s/img.jpg: %d %s", tt.token, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireJPEGXL requires that rec answers 200 with a JPEG XL image.
|
||||||
|
func requireJPEGXL(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body %q",
|
||||||
|
rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != jxlType {
|
||||||
|
t.Errorf("Content-Type = %q, want %s", got, jxlType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
|
||||||
|
t.Errorf("body is %s, want jxl", vips.ImageTypes[got])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageWithoutFormat_ServesJPEGXL verifies that a /v1/image/ URL whose
|
||||||
|
// last segment is a size with no format, 50x50 or orig, answers JPEG XL.
|
||||||
|
func TestImageWithoutFormat_ServesJPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
route := newImageRoute(t, newPhotoFetcher(t, allowlistedHost))
|
||||||
|
|
||||||
|
for _, size := range []string{"50x50", "orig"} {
|
||||||
|
target := "/v1/image/" + allowlistedHost + photoPath + "/" + size
|
||||||
|
requireJPEGXL(t, sendGet(t, route, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageWithoutFormat_SignedAsJXL verifies that a /v1/image/ URL with no
|
||||||
|
// format is signed as jxl: the signature made for the URL ending in .jxl is
|
||||||
|
// accepted for the same URL without .jxl.
|
||||||
|
func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: string(imgcache.FormatJXL),
|
||||||
|
Quality: encurl.DefaultQuality,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
|
||||||
|
|
||||||
|
for _, size := range []string{"50x50.jxl", "50x50"} {
|
||||||
|
target := "/v1/image/" + signedHost + photoPath + "/" + size + query
|
||||||
|
requireJPEGXL(t, sendGet(t, route, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageEncWithoutFormat_ServesJPEGXL verifies that an encrypted URL whose
|
||||||
|
// token holds no format answers JPEG XL.
|
||||||
|
func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
token, err := h.encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requireJPEGXL(t, getEncToken(srv, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratorPage_SelectsJPEGXL verifies that the generator page's format
|
||||||
|
// choice is JPEG XL until another is chosen.
|
||||||
|
func TestGeneratorPage_SelectsJPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(newSessionCookie(t, h))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), `<option value="jxl" selected>`) {
|
||||||
|
t.Errorf("generator page does not select JPEG XL: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_NoFormat_MakesJPEGXLURL verifies that the generator form
|
||||||
|
// sent with an empty format field, or with none, makes a URL whose name ends
|
||||||
|
// in .jxl and which answers JPEG XL.
|
||||||
|
func TestGeneratePost_NoFormat_MakesJPEGXLURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
photo := url.Values{
|
||||||
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
|
widthField: {"50"},
|
||||||
|
heightField: {"50"},
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyFormat := maps.Clone(photo)
|
||||||
|
emptyFormat.Set(formatField, "")
|
||||||
|
|
||||||
|
for name, form := range map[string]url.Values{
|
||||||
|
"empty format field": emptyFormat,
|
||||||
|
"no format field": photo,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
rec := generatePost(t, form)
|
||||||
|
|
||||||
|
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("generator page shows no URL: %d %s",
|
||||||
|
rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("generated URL path: %s", match[1])
|
||||||
|
|
||||||
|
if !strings.HasSuffix(match[1], "/img.jxl") {
|
||||||
|
t.Errorf("generated URL %s does not end in /img.jxl", match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
imageRec := httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
requireJPEGXL(t, imageRec)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jxlType is the content type of JPEG XL.
|
||||||
|
const jxlType = "image/jxl"
|
||||||
|
|
||||||
|
// TestFormatForAccept_JPEGXL verifies that the format auto chooses JPEG XL
|
||||||
|
// when Accept names image/jxl, ahead of AVIF whatever their q, and AVIF when
|
||||||
|
// Accept refuses JPEG XL with q=0.
|
||||||
|
func TestFormatForAccept_JPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accept string
|
||||||
|
want imgcache.ImageFormat
|
||||||
|
}{
|
||||||
|
{"JPEG XL-capable browser",
|
||||||
|
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
|
||||||
|
{"JPEG XL only", jxlType, imgcache.FormatJXL},
|
||||||
|
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
||||||
|
imgcache.FormatJXL},
|
||||||
|
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := formatForAccept(tt.accept)
|
||||||
|
if got != tt.want || err != nil {
|
||||||
|
t.Errorf("formatForAccept(%q) = %q, %v, want %q",
|
||||||
|
tt.accept, got, err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuto_JPEGXL requests an auto URL on each image route with an
|
||||||
|
// Accept header that names image/jxl, and checks that the answer is a JPEG XL
|
||||||
|
// image.
|
||||||
|
func TestFormatAuto_JPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
signedURL, encryptedURL := autoPhotoURLs(t, h)
|
||||||
|
|
||||||
|
for _, target := range []string{signedURL, encryptedURL} {
|
||||||
|
rec := requestImage(t, srv, http.MethodGet, target,
|
||||||
|
"image/jxl,image/avif,image/webp,*/*;q=0.8")
|
||||||
|
gotType := rec.Header().Get("Content-Type")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || gotType != jxlType {
|
||||||
|
t.Errorf("%s: %d %s, want 200 %s; body %s",
|
||||||
|
target, rec.Code, gotType, jxlType, rec.Body)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
|
||||||
|
t.Errorf("%s: body is %s, want jxl", target, vips.ImageTypes[got])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
|
||||||
|
// referer_blocklist.
|
||||||
|
const blockedReferer = "https://leech.example/page.html"
|
||||||
|
|
||||||
|
// countingFetcher passes each fetch on to the fetcher it holds and counts it.
|
||||||
|
type countingFetcher struct {
|
||||||
|
httpfetcher.Fetcher
|
||||||
|
|
||||||
|
fetches atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch counts the fetch and passes it on.
|
||||||
|
func (f *countingFetcher) Fetch(
|
||||||
|
ctx context.Context, url string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
f.fetches.Add(1)
|
||||||
|
|
||||||
|
return f.Fetcher.Fetch(ctx, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRefererRoutes returns both image routes of a Handlers whose
|
||||||
|
// referer_blocklist is "leech.example" and ".hotlinker.example", the
|
||||||
|
// Handlers, and the fetcher the routes fetch through. The JPEG at photoPath
|
||||||
|
// exists on allowlistedHost and on signedHost.
|
||||||
|
func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fetcher := &countingFetcher{
|
||||||
|
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: fetcher,
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
Allowlist: []string{allowlistedHost},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
imgSvc: svc,
|
||||||
|
encGen: encGen,
|
||||||
|
refererBlocklist: allowlist.New(
|
||||||
|
[]string{"leech.example", ".hotlinker.example"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return r, h, fetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWithReferer sends a GET for target to routes with referer as its
|
||||||
|
// Referer header, or with none when referer is empty, and returns the
|
||||||
|
// response.
|
||||||
|
func getWithReferer(
|
||||||
|
t *testing.T, routes http.Handler, target, referer string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
if referer != "" {
|
||||||
|
req.Header.Set("Referer", referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
routes.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET %s with Referer %q: %d", target, referer, rec.Code)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefererBlocklist verifies that both image routes refuse a request whose
|
||||||
|
// Referer names a host on referer_blocklist with 403 and the JSON error,
|
||||||
|
// without fetching from the upstream host, and serve a request with no
|
||||||
|
// Referer, one that does not parse, or one naming any other host. Hosts are
|
||||||
|
// matched as allowlist_hosts matches them.
|
||||||
|
func TestRefererBlocklist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
referer string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"no referer", "", http.StatusOK},
|
||||||
|
{"unlisted host", "https://unlisted.example/page.html", http.StatusOK},
|
||||||
|
{"unparseable", "%zz", http.StatusOK},
|
||||||
|
{"listed host", blockedReferer, http.StatusForbidden},
|
||||||
|
{"subdomain of listed host", "https://www.leech.example/", http.StatusOK},
|
||||||
|
{"subdomain of dot pattern", "https://www.hotlinker.example/a.html",
|
||||||
|
http.StatusForbidden},
|
||||||
|
{"dot pattern without its dot", "https://hotlinker.example/",
|
||||||
|
http.StatusForbidden},
|
||||||
|
{"host continuing past dot pattern",
|
||||||
|
"https://hotlinker.example.evil.example/", http.StatusOK},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The photo's URL on each image route.
|
||||||
|
photoURLs := map[string]func(t *testing.T, h *Handlers) string{
|
||||||
|
"plain URL": func(t *testing.T, _ *Handlers) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return photoURL(allowlistedHost)
|
||||||
|
},
|
||||||
|
"encrypted URL": encPhotoURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
for urlName, photoURLFor := range photoURLs {
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(urlName+", "+tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
routes, h, fetcher := newRefererRoutes(t)
|
||||||
|
|
||||||
|
rec := getWithReferer(t, routes, photoURLFor(t, h), tc.referer)
|
||||||
|
|
||||||
|
if tc.want == http.StatusOK {
|
||||||
|
requireServedPhoto(t, rec)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
|
||||||
|
|
||||||
|
if n := fetcher.fetches.Load(); n != 0 {
|
||||||
|
t.Errorf("upstream fetched %d times, want 0", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedRefererRefusedWhenImageIsCached verifies that a request whose
|
||||||
|
// Referer is on referer_blocklist is refused even when the image it asks for
|
||||||
|
// is already cached, so the answer does not depend on the cache.
|
||||||
|
func TestBlockedRefererRefusedWhenImageIsCached(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
routes, h, _ := newRefererRoutes(t)
|
||||||
|
|
||||||
|
for _, target := range []string{photoURL(allowlistedHost), encPhotoURL(t, h)} {
|
||||||
|
requireServedPhoto(t, getWithReferer(t, routes, target, ""))
|
||||||
|
|
||||||
|
rec := getWithReferer(t, routes, target, blockedReferer)
|
||||||
|
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleRobotsTxt checks that /robots.txt asks every crawler to stay off
|
||||||
|
// the whole site.
|
||||||
|
func TestHandleRobotsTxt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
rec := sendGet(t, h.HandleRobotsTxt(), "/robots.txt")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
|
||||||
|
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "User-agent: *\nDisallow: /\n"
|
||||||
|
if rec.Body.String() != want {
|
||||||
|
t.Errorf("body = %q, want %q", rec.Body.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHealthCheck checks that the health check answers 200 with status
|
||||||
|
// ok, the app's name and version, now, uptime_seconds, uptime_human and
|
||||||
|
// maintenance_mode, which is true here: the health check stays 200 while
|
||||||
|
// maintenance mode is on.
|
||||||
|
func TestHandleHealthCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hc, err := healthcheck.New(lc, healthcheck.Params{
|
||||||
|
Globals: &globals.Globals{Appname: "pixad", Version: "v1.2.3"},
|
||||||
|
Config: &config.Config{MaintenanceMode: true},
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("healthcheck.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{hc: hc, log: slog.New(slog.DiscardHandler)}
|
||||||
|
rec := sendGet(t, h.HandleHealthCheck(), "/.well-known/healthcheck.json")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
|
||||||
|
err = json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body["status"] != "ok" || body["appname"] != "pixad" ||
|
||||||
|
body["version"] != "v1.2.3" || body["maintenance_mode"] != true {
|
||||||
|
t.Errorf("body = %v, want status ok, appname pixad, version v1.2.3 "+
|
||||||
|
"and maintenance_mode true", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{"now", "uptime_seconds", "uptime_human"} {
|
||||||
|
if _, ok := body[key]; !ok {
|
||||||
|
t.Errorf("body = %v, has no %s", body, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -47,3 +48,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, &imgcache.ImageRequest{}, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"respondImageError": plain, "handleImageError": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("%s: status = %d, want %d",
|
||||||
|
name, rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
|
t.Errorf("%s: body = %q, want the server busy error",
|
||||||
|
name, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
|
||||||
|
// 415 with a clear error when the upstream answers with a content type pixa
|
||||||
|
// does not accept, such as SVG, and when orig is asked of a source in a
|
||||||
|
// format the processor cannot name, wrapped as the service wraps them.
|
||||||
|
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"}
|
||||||
|
|
||||||
|
for _, err := range []error{
|
||||||
|
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
||||||
|
httpfetcher.ErrInvalidContentType),
|
||||||
|
fmt.Errorf("image processing failed: %w: unknown",
|
||||||
|
imageprocessor.ErrUnsupportedInputFormat),
|
||||||
|
} {
|
||||||
|
plain := httptest.NewRecorder()
|
||||||
|
h.respondImageError(plain, req, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for route, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"HandleImage": plain, "HandleImageEnc": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s for %v: %d %s", route, err, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("%s for %v: status = %d, want %d",
|
||||||
|
route, err, rec.Code, http.StatusUnsupportedMediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||||
|
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
|
||||||
|
route, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
||||||
|
// without DialContext, as pixa builds it, refuses to connect to a local
|
||||||
|
// server.
|
||||||
|
func TestNewUsesCheckedDialerWithoutDialContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
transport := transportOf(t, New(DefaultConfig()))
|
||||||
|
|
||||||
|
addr := srv.Listener.Addr().String()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(testContext(t), "tcp", addr)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Fatalf("DialContext(%s) error = %v, want ErrSSRFBlocked", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDialContextReplacesOnlyTheDialer checks that a fetcher built with
|
||||||
|
// DialContext connects through it, while the URL check still refuses a
|
||||||
|
// loopback URL and the redirect check a redirect to a link-local address.
|
||||||
|
func TestDialContextReplacesOnlyTheDialer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.AllowHTTP = true
|
||||||
|
cfg.DialContext = dialer.dialContext
|
||||||
|
f := New(cfg)
|
||||||
|
|
||||||
|
if body := fetchBody(t, f, "/image"); body != imagePayload {
|
||||||
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upstream server is reached through DialContext, and nothing else
|
||||||
|
// is asked of it.
|
||||||
|
dialed := dialer.dialedAddrs()
|
||||||
|
if len(dialed) == 0 {
|
||||||
|
t.Error("DialContext was never called")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range dialed {
|
||||||
|
if addr != net.JoinHostPort(testPublicHost, "80") {
|
||||||
|
t.Errorf("DialContext was asked to connect to %s", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -137,6 +138,11 @@ type Config struct {
|
|||||||
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||||
// dialer, in addition to the always-enforced built-in ranges.
|
// dialer, in addition to the always-enforced built-in ranges.
|
||||||
BlockedNetworks []netip.Prefix
|
BlockedNetworks []netip.Prefix
|
||||||
|
// DialContext, when set, makes the fetcher's connections in place of
|
||||||
|
// the dialer that refuses internal addresses; the URL and redirect
|
||||||
|
// checks still run. Only tests set it, to reach a local server; the
|
||||||
|
// config file and the environment cannot.
|
||||||
|
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns a Config with sensible defaults.
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
@@ -151,7 +157,7 @@ func DefaultConfig() *Config {
|
|||||||
contentTypeGIF,
|
contentTypeGIF,
|
||||||
contentTypeWebP,
|
contentTypeWebP,
|
||||||
contentTypeAVIF,
|
contentTypeAVIF,
|
||||||
contentTypeSVG,
|
contentTypeJXL,
|
||||||
},
|
},
|
||||||
AllowHTTP: false,
|
AllowHTTP: false,
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
@@ -190,13 +196,19 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
config = DefaultConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
// Unless config.DialContext replaces it, the transport connects with
|
||||||
// re-checks at connect time (closing the DNS-rebinding window) against
|
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
||||||
// both the built-in ranges and the operator-supplied blocklist.
|
// (closing the DNS-rebinding window) against both the built-in ranges
|
||||||
transport := &http.Transport{
|
// and the operator-supplied blocklist.
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
dialContext := config.DialContext
|
||||||
|
if dialContext == nil {
|
||||||
|
dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialContext,
|
||||||
TLSHandshakeTimeout: DefaultTLSTimeout,
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||||
MaxIdleConns: DefaultMaxIdleConns,
|
MaxIdleConns: DefaultMaxIdleConns,
|
||||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||||
@@ -648,14 +660,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
|
||||||
|
// of more than MaxAVIFOutputPixels is refused with ErrAVIFOutputTooLarge
|
||||||
|
// before anything is decoded, whatever size and fit give it, and that an AVIF
|
||||||
|
// output of at most that many pixels, or one in another format, is not. The
|
||||||
|
// input is a 64x32 PNG cut off in its pixel data: libvips reads its size from
|
||||||
|
// its header but fails when it decodes it, so a request that gets that failure
|
||||||
|
// was not refused, and a refusal came before any decoding.
|
||||||
|
func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
whole := createTestPNG(t, 64, 32)
|
||||||
|
cutOff := whole[:len(whole)/2]
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
format Format
|
||||||
|
width, height int
|
||||||
|
fit FitMode
|
||||||
|
refused bool
|
||||||
|
}{
|
||||||
|
{"AVIF at the limit", FormatAVIF, 4096, 4096, FitCover, false},
|
||||||
|
{"AVIF above the limit", FormatAVIF, 4096, 4097, FitCover, true},
|
||||||
|
{"WebP above the AVIF limit", FormatWebP, 4096, 4097, FitCover, false},
|
||||||
|
// FitInside does not enlarge, so the output stays 64x32
|
||||||
|
{"AVIF inside a larger size", FormatAVIF, 8192, 8192, FitInside, false},
|
||||||
|
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
|
||||||
|
{"AVIF outside a smaller size", FormatAVIF, 8192, 1, FitOutside, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := New(Params{}).Process(t.Context(), bytes.NewReader(cutOff),
|
||||||
|
&Request{
|
||||||
|
Size: Size{Width: tt.width, Height: tt.height},
|
||||||
|
Format: tt.format,
|
||||||
|
FitMode: tt.fit,
|
||||||
|
})
|
||||||
|
t.Logf("Process() error = %v", err)
|
||||||
|
|
||||||
|
if tt.refused {
|
||||||
|
if !errors.Is(err, ErrAVIFOutputTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, ErrAVIFOutputTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil || errors.Is(err, ErrAVIFOutputTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want the failure to decode "+
|
||||||
|
"the cut-off input", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
|
||||||
|
// with one side 0, which Process computes from the input's proportions, is
|
||||||
|
// served when that side comes out at MaxOutputDimension and refused with
|
||||||
|
// ErrOutputTooLarge when it would be above. A 4096x1 or 1x4096 input given
|
||||||
|
// 2 for its short side has 8192 for its long side.
|
||||||
|
func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputWidth, inputHeight int
|
||||||
|
size Size
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"width at the maximum", 4096, 1, Size{Height: 2}, nil},
|
||||||
|
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge},
|
||||||
|
{"height at the maximum", 1, 4096, Size{Width: 2}, nil},
|
||||||
|
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input),
|
||||||
|
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
|
||||||
|
if result != nil {
|
||||||
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
|
if max(result.Width, result.Height) != MaxOutputDimension {
|
||||||
|
t.Errorf("Process() made %dx%d, want a side of %d",
|
||||||
|
result.Width, result.Height, MaxOutputDimension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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, smallJPEGRequest())
|
||||||
|
if result != nil {
|
||||||
|
t.Logf("Process() made a %dx%d image after its deadline passed",
|
||||||
|
result.Width, result.Height)
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Process() error = %v", err)
|
||||||
|
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("Process() error = %v, want context.DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(proc.processingSemaphore); held != 0 {
|
||||||
|
t.Errorf("%d slots still held after Process() returned", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
|
||||||
|
// is refused, as both image routes give every request a format before it is
|
||||||
|
// processed.
|
||||||
|
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := New(Params{}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processedSize runs input through Process and returns the output's size in
|
||||||
|
// bytes.
|
||||||
|
func processedSize(t *testing.T, input []byte, req *Request) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
|
||||||
|
return result.ContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodePNG encodes img as PNG with Go's encoder.
|
||||||
|
func encodePNG(t *testing.T, img image.Image) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode decodes input with vips, as Process does.
|
||||||
|
func decode(t *testing.T, input []byte) *vips.ImageRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img, err := vips.NewImageFromBuffer(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(img.Close)
|
||||||
|
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_PNGIsCompressed verifies that a PNG output is
|
||||||
|
// compressed: a flat 200x150 image, 90,000 bytes of raw pixels, comes out at
|
||||||
|
// a small fraction of that.
|
||||||
|
func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 200, 150
|
||||||
|
|
||||||
|
flat := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
flat.Set(x, y, color.RGBA{R: 40, G: 120, B: 200, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
|
||||||
|
|
||||||
|
const rawBytes = width * height * 3
|
||||||
|
if size > rawBytes/10 {
|
||||||
|
t.Errorf("PNG output is %d bytes, want under a tenth of its %d raw",
|
||||||
|
size, rawBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_WebPAtEffort2 verifies that WebP is saved at effort 2,
|
||||||
|
// not libvips' default of 4: the output is the size of the same image saved
|
||||||
|
// at effort 2.
|
||||||
|
func TestImageProcessor_WebPAtEffort2(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
ReductionEffort: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportWebp() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("WebP output is %d bytes, want %d, the size at effort 2",
|
||||||
|
size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_AVIFAtEffort1 verifies that AVIF is saved at effort 1
|
||||||
|
// with 8 bits per sample: the output is the size of the same image saved
|
||||||
|
// with those settings. The source is 16-bit, which libvips saves with 12
|
||||||
|
// bits when it is not given a bit depth, and 640x480: on a small image,
|
||||||
|
// such as 64x48, efforts 1 and 2 give the same output.
|
||||||
|
func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 640, 480
|
||||||
|
|
||||||
|
source := image.NewRGBA64(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
source.Set(x, y, color.RGBA64{
|
||||||
|
R: uint16((x * 65535 / width) & 0xffff),
|
||||||
|
G: uint16((y * 65535 / height) & 0xffff),
|
||||||
|
B: 32768,
|
||||||
|
A: 65535,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
Effort: 1,
|
||||||
|
Bitdepth: 8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportAvif() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("AVIF output is %d bytes, want %d, the size at effort 1 "+
|
||||||
|
"and 8 bits", size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLAtEffort4 verifies that JPEG XL is saved at
|
||||||
|
// effort 4, not libvips' default of 7: the output is the size of the same
|
||||||
|
// image saved at effort 4, with the distance exportJXL gives quality 85.
|
||||||
|
func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatJXL, Quality: 85})
|
||||||
|
|
||||||
|
params := vips.NewJxlExportParams()
|
||||||
|
params.Quality = 85
|
||||||
|
params.Distance = jxlDistance(85)
|
||||||
|
params.Effort = 4
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportJxl(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportJxl() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("JPEG XL output is %d bytes, want %d, the size at effort 4",
|
||||||
|
size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
|
||||||
|
// which scales the input to cover the requested size and so past it on one
|
||||||
|
// side, is refused with ErrOutputTooLarge when that side would come out above
|
||||||
|
// MaxOutputDimension, and otherwise makes the scaled size. A 1024x256 or
|
||||||
|
// 256x1024 input scaled by 8 has 8192 for its long side.
|
||||||
|
func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputWidth, inputHeight int
|
||||||
|
size Size
|
||||||
|
// wantWidth and wantHeight are the output's, 0 for a refusal
|
||||||
|
wantWidth, wantHeight int
|
||||||
|
}{
|
||||||
|
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0},
|
||||||
|
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0},
|
||||||
|
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048},
|
||||||
|
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192},
|
||||||
|
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input),
|
||||||
|
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
||||||
|
if result != nil {
|
||||||
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantWidth == 0 {
|
||||||
|
if !errors.Is(err, ErrOutputTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, ErrOutputTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Width != tt.wantWidth || result.Height != tt.wantHeight {
|
||||||
|
t.Errorf("Process() made %dx%d, want %dx%d",
|
||||||
|
result.Width, result.Height, tt.wantWidth, tt.wantHeight)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
@@ -20,11 +20,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,6 +36,24 @@ func initVips() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errNoJPEGXL is returned by CheckJPEGXLSupport.
|
||||||
|
var errNoJPEGXL = errors.New("libvips lacks JPEG XL support: install " +
|
||||||
|
"vips-jxl on Alpine, or use a libvips built with libjxl")
|
||||||
|
|
||||||
|
// CheckJPEGXLSupport returns an error, naming the fix, when libvips
|
||||||
|
// cannot load and save JPEG XL.
|
||||||
|
func CheckJPEGXLSupport() error {
|
||||||
|
initVips()
|
||||||
|
|
||||||
|
// govips counts a format as supported when libvips has its loader;
|
||||||
|
// libvips builds the JPEG XL loader and saver together.
|
||||||
|
if !vips.IsTypeSupported(vips.ImageTypeJXL) {
|
||||||
|
return errNoJPEGXL
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Format represents supported output image formats.
|
// Format represents supported output image formats.
|
||||||
type Format string
|
type Format string
|
||||||
|
|
||||||
@@ -47,6 +64,7 @@ const (
|
|||||||
FormatPNG Format = "png"
|
FormatPNG Format = "png"
|
||||||
FormatWebP Format = "webp"
|
FormatWebP Format = "webp"
|
||||||
FormatAVIF Format = "avif"
|
FormatAVIF Format = "avif"
|
||||||
|
FormatJXL Format = "jxl"
|
||||||
FormatGIF Format = "gif"
|
FormatGIF Format = "gif"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,10 +132,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 (imgcache.MaxDimension).
|
||||||
|
const MaxOutputDimension = 8192
|
||||||
|
|
||||||
|
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
||||||
|
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
||||||
|
// from the input's proportions, or with FitOutside, the side scaled past the
|
||||||
|
// requested size.
|
||||||
|
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
|
||||||
|
|
||||||
|
// MaxAVIFOutputPixels is the most pixels an AVIF output may have, so that an
|
||||||
|
// encode takes at most 40 seconds of the default downstream_timeout of 60
|
||||||
|
// seconds; AVIF is already at the lowest effort govips can set (avifEffort).
|
||||||
|
// With one thread, as pixad runs libvips, saving 4096x4096, 8192x2048 or
|
||||||
|
// 2048x8192 random pixels with an alpha channel takes up to about 34
|
||||||
|
// seconds, the slowest at any quality and at 8 or 16 bits per sample, and
|
||||||
|
// 5120x4096 at 8 bits already takes about 36.
|
||||||
|
const MaxAVIFOutputPixels = 4096 * 4096
|
||||||
|
|
||||||
|
// ErrAVIFOutputTooLarge is returned, before anything is decoded, when an AVIF
|
||||||
|
// output would have more than MaxAVIFOutputPixels pixels.
|
||||||
|
var ErrAVIFOutputTooLarge = errors.New("AVIF output has too many pixels")
|
||||||
|
|
||||||
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
// 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 +191,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 +207,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{
|
||||||
@@ -173,7 +220,11 @@ func New(params Params) *ImageProcessor {
|
|||||||
// Process transforms an image according to the request. When
|
// Process transforms an image according to the request. 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,
|
||||||
@@ -186,18 +237,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 +271,41 @@ func (p *ImageProcessor) Process(
|
|||||||
origWidth := img.Width()
|
origWidth := img.Width()
|
||||||
origHeight := img.Height()
|
origHeight := img.Height()
|
||||||
|
|
||||||
// Detect input format
|
|
||||||
inputFormat := p.detectFormat(img)
|
|
||||||
|
|
||||||
// Validate input dimensions to prevent DoS via decompression bombs
|
// Validate input dimensions to prevent DoS via decompression bombs
|
||||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||||
return nil, ErrInputTooLarge
|
return nil, ErrInputTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine target dimensions
|
// Determine target dimensions; too large ones are refused before resizing
|
||||||
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// orig is the source's own format; encode refuses an empty format
|
||||||
|
outputFormat := req.Format
|
||||||
|
if outputFormat == FormatOriginal {
|
||||||
|
outputFormat = sourceFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output dimensions; too large ones are refused before
|
||||||
|
// resizing, which with FitCover already decodes the input
|
||||||
|
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
||||||
|
targetWidth, targetHeight, req.FitMode, outputFormat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 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, req.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, req.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 +324,29 @@ func (p *ImageProcessor) Process(
|
|||||||
|
|
||||||
// targetDimensions calculates the output dimensions for a requested size,
|
// targetDimensions calculates the output dimensions for a requested size,
|
||||||
// scaling proportionally when only one dimension is given and keeping the
|
// scaling proportionally when only one dimension is given and keeping the
|
||||||
// original dimensions when both are zero.
|
// original dimensions when both are zero. A side scaled proportionally can
|
||||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
// come out above MaxOutputDimension, which is ErrOutputTooLarge.
|
||||||
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
||||||
|
width, height := size.Width, size.Height
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case size.Width == 0 && size.Height == 0:
|
case width == 0 && height == 0:
|
||||||
// Both are 0: keep original size
|
// Both are 0: keep original size
|
||||||
return origWidth, origHeight
|
return origWidth, origHeight, nil
|
||||||
case size.Width == 0:
|
case width == 0:
|
||||||
// Only height specified: calculate width proportionally
|
// Only height specified: calculate width proportionally
|
||||||
return origWidth * size.Height / origHeight, size.Height
|
width = origWidth * height / origHeight
|
||||||
case size.Height == 0:
|
case height == 0:
|
||||||
// Only width specified: calculate height proportionally
|
// Only width specified: calculate height proportionally
|
||||||
return size.Width, origHeight * size.Width / origWidth
|
height = origHeight * width / origWidth
|
||||||
default:
|
|
||||||
return size.Width, size.Height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if width > MaxOutputDimension || height > MaxOutputDimension {
|
||||||
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
|
||||||
|
width, height, MaxOutputDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return width, height, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIME types for the supported image formats.
|
// MIME types for the supported image formats.
|
||||||
@@ -287,6 +356,7 @@ const (
|
|||||||
mimeGIF = "image/gif"
|
mimeGIF = "image/gif"
|
||||||
mimeWebP = "image/webp"
|
mimeWebP = "image/webp"
|
||||||
mimeAVIF = "image/avif"
|
mimeAVIF = "image/avif"
|
||||||
|
mimeJXL = "image/jxl"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read.
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
@@ -297,6 +367,7 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
|
|||||||
mimeGIF,
|
mimeGIF,
|
||||||
mimeWebP,
|
mimeWebP,
|
||||||
mimeAVIF,
|
mimeAVIF,
|
||||||
|
mimeJXL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +379,7 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
|||||||
FormatGIF,
|
FormatGIF,
|
||||||
FormatWebP,
|
FormatWebP,
|
||||||
FormatAVIF,
|
FormatAVIF,
|
||||||
|
FormatJXL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +396,8 @@ func FormatToMIME(format Format) string {
|
|||||||
return mimeGIF
|
return mimeGIF
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return mimeAVIF
|
return mimeAVIF
|
||||||
|
case FormatJXL:
|
||||||
|
return mimeJXL
|
||||||
case FormatOriginal:
|
case FormatOriginal:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
default:
|
default:
|
||||||
@@ -358,31 +432,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:
|
||||||
@@ -393,16 +486,72 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
|||||||
return "webp"
|
return "webp"
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
return string(FormatAVIF)
|
return string(FormatAVIF)
|
||||||
|
case vips.ImageTypeJXL:
|
||||||
|
return string(FormatJXL)
|
||||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
vips.ImageTypeJP2K, vips.ImageTypeJXL:
|
vips.ImageTypeJP2K:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize resizes the image according to the fit mode.
|
// outputDimensions returns the dimensions resize gives an image of imgWidth x
|
||||||
|
// imgHeight for the target dimensions and fit mode, without resizing it, and
|
||||||
|
// refuses an output too large for format. With FitOutside one side can come
|
||||||
|
// out larger than the target; when it would be above MaxOutputDimension,
|
||||||
|
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
|
||||||
|
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
|
||||||
|
func outputDimensions(
|
||||||
|
imgWidth, imgHeight, width, height int, fit FitMode, format Format,
|
||||||
|
) (int, int, error) {
|
||||||
|
scaleW := float64(width) / float64(imgWidth)
|
||||||
|
scaleH := float64(height) / float64(imgHeight)
|
||||||
|
outputWidth, outputHeight := width, height
|
||||||
|
|
||||||
|
switch fit {
|
||||||
|
case FitCover, FitFill, "":
|
||||||
|
// Cover crops, and fill stretches, to exactly the target dimensions
|
||||||
|
|
||||||
|
case FitContain:
|
||||||
|
// Fit within the target dimensions, maintaining aspect ratio
|
||||||
|
scale := min(scaleW, scaleH)
|
||||||
|
outputWidth = int(float64(imgWidth) * scale)
|
||||||
|
outputHeight = int(float64(imgHeight) * scale)
|
||||||
|
|
||||||
|
case FitInside:
|
||||||
|
// Same as contain, but only shrink
|
||||||
|
scale := min(scaleW, scaleH, 1)
|
||||||
|
outputWidth = int(float64(imgWidth) * scale)
|
||||||
|
outputHeight = int(float64(imgHeight) * scale)
|
||||||
|
|
||||||
|
case FitOutside:
|
||||||
|
// The smallest dimension fits, the other may exceed the target
|
||||||
|
scale := max(scaleW, scaleH)
|
||||||
|
outputWidth = int(float64(imgWidth) * scale)
|
||||||
|
outputHeight = int(float64(imgHeight) * scale)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0, 0, fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The side FitOutside scales past the target is held to the maximum too
|
||||||
|
if max(outputWidth, outputHeight) > MaxOutputDimension {
|
||||||
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
|
||||||
|
outputWidth, outputHeight, MaxOutputDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
if format == FormatAVIF && outputWidth*outputHeight > MaxAVIFOutputPixels {
|
||||||
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
|
||||||
|
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputWidth, outputHeight, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize resizes the image to width x height, the dimensions
|
||||||
|
// outputDimensions gives for the fit mode.
|
||||||
func (p *ImageProcessor) resize(
|
func (p *ImageProcessor) resize(
|
||||||
img *vips.ImageRef, width, height int, fit FitMode,
|
img *vips.ImageRef, width, height int, fit FitMode,
|
||||||
) error {
|
) error {
|
||||||
@@ -411,46 +560,20 @@ func (p *ImageProcessor) resize(
|
|||||||
// Resize and crop to fill exact dimensions (default)
|
// Resize and crop to fill exact dimensions (default)
|
||||||
return img.Thumbnail(width, height, vips.InterestingCentre)
|
return img.Thumbnail(width, height, vips.InterestingCentre)
|
||||||
|
|
||||||
case FitContain:
|
|
||||||
// Resize to fit within dimensions, maintaining aspect ratio
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
|
||||||
scaleW := float64(width) / float64(imgW)
|
|
||||||
scaleH := float64(height) / float64(imgH)
|
|
||||||
scale := min(scaleW, scaleH)
|
|
||||||
newW := int(float64(imgW) * scale)
|
|
||||||
newH := int(float64(imgH) * scale)
|
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
|
||||||
|
|
||||||
case FitFill:
|
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 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 FitContain, 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", ErrInvalidFitMode, fit)
|
||||||
@@ -459,50 +582,19 @@ func (p *ImageProcessor) resize(
|
|||||||
|
|
||||||
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 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 +602,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 FormatJPEG:
|
||||||
params.StripMetadata = true
|
return exportJPEG(img, quality)
|
||||||
|
|
||||||
output, _, err := img.Export(¶ms)
|
case FormatPNG:
|
||||||
|
return exportPNG(img)
|
||||||
|
|
||||||
|
case FormatGIF:
|
||||||
|
return exportGIF(img)
|
||||||
|
|
||||||
|
case FormatWebP:
|
||||||
|
return exportWebP(img, quality)
|
||||||
|
|
||||||
|
case FormatAVIF:
|
||||||
|
return exportAVIF(img, quality)
|
||||||
|
|
||||||
|
case FormatJXL:
|
||||||
|
return exportJXL(img, quality)
|
||||||
|
|
||||||
|
case FormatOriginal:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// govips sends libvips Go's zero value for some settings an export leaves
|
||||||
|
// out, such as no compression at all for PNG, so each export below sets
|
||||||
|
// every setting whose zero value is not what pixa wants. Stripping metadata
|
||||||
|
// drops EXIF, XMP, IPTC and the ICC profile.
|
||||||
|
|
||||||
|
// exportJPEG encodes img as JPEG at quality, without metadata. The settings
|
||||||
|
// it leaves out are at libvips' defaults.
|
||||||
|
func exportJPEG(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportJpeg(&vips.JpegExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: quality,
|
||||||
|
})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pngCompression is libvips' default PNG compression, from 0 (none) to 9.
|
||||||
|
const pngCompression = 6
|
||||||
|
|
||||||
|
// exportPNG encodes img as PNG at libvips' default compression and row
|
||||||
|
// filter, without metadata.
|
||||||
|
func exportPNG(img *vips.ImageRef) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportPng(&vips.PngExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Compression: pngCompression,
|
||||||
|
Filter: vips.PngFilterNone,
|
||||||
|
})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// gifEffort is libvips' default GIF effort, from 1 to 10.
|
||||||
|
const gifEffort = 7
|
||||||
|
|
||||||
|
// exportGIF encodes img as GIF at libvips' default effort. govips cannot
|
||||||
|
// have libvips strip metadata from GIF, which carries none.
|
||||||
|
func exportGIF(img *vips.ImageRef) ([]byte, error) {
|
||||||
|
output, _, err := img.ExportGIF(&vips.GifExportParams{Effort: gifEffort})
|
||||||
|
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// webpEffort is 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 +791,39 @@ func (p *ImageProcessor) encode(
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatFromString converts a format string to Format.
|
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
|
||||||
func (p *ImageProcessor) formatFromString(format string) Format {
|
// distance, with the formula libvips and libjxl use for their own quality
|
||||||
|
// setting, except that 100 stays lossy (distance 0.1) where libjxl makes it
|
||||||
|
// lossless.
|
||||||
|
//
|
||||||
|
//nolint:mnd // the constants of that formula
|
||||||
|
func jxlDistance(quality int) float64 {
|
||||||
|
q := float64(quality)
|
||||||
|
|
||||||
|
if quality >= 30 {
|
||||||
|
return 0.1 + (100-q)*0.09
|
||||||
|
}
|
||||||
|
|
||||||
|
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFromString converts a format string from detectFormat to Format. A
|
||||||
|
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
||||||
|
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
|
||||||
switch format {
|
switch format {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return FormatJPEG
|
return FormatJPEG, nil
|
||||||
case "png":
|
case "png":
|
||||||
return FormatPNG
|
return FormatPNG, nil
|
||||||
case "gif":
|
case "gif":
|
||||||
return FormatGIF
|
return FormatGIF, nil
|
||||||
case "webp":
|
case "webp":
|
||||||
return FormatWebP
|
return FormatWebP, nil
|
||||||
case string(FormatAVIF):
|
case string(FormatAVIF):
|
||||||
return FormatAVIF
|
return FormatAVIF, nil
|
||||||
|
case string(FormatJXL):
|
||||||
|
return FormatJXL, nil
|
||||||
default:
|
default:
|
||||||
return FormatJPEG
|
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCheckJPEGXLSupport fails when libvips lacks JPEG XL support, as on
|
||||||
|
// Alpine without the vips-jxl package.
|
||||||
|
func TestCheckJPEGXLSupport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := CheckJPEGXLSupport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckJPEGXLSupport() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibvipsSavesAndLoadsJPEGXL saves an image as JPEG XL with govips and
|
||||||
|
// loads it back. It fails when libvips lacks JPEG XL support, as on Alpine
|
||||||
|
// without the vips-jxl package.
|
||||||
|
func TestLibvipsSavesAndLoadsJPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
img, err := vips.NewImageFromBuffer(createTestJPEG(t, 64, 48))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer img.Close()
|
||||||
|
|
||||||
|
jxl, _, err := img.ExportJxl(vips.NewJxlExportParams())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportJxl() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := vips.NewImageFromBuffer(jxl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load the JPEG XL image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer loaded.Close()
|
||||||
|
|
||||||
|
if loaded.Format() != vips.ImageTypeJXL {
|
||||||
|
t.Errorf("loaded format = %s, want jxl", vips.ImageTypes[loaded.Format()])
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Width() != 64 || loaded.Height() != 48 {
|
||||||
|
t.Errorf("loaded size = %dx%d, want 64x48", loaded.Width(), loaded.Height())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
||||||
|
// checks the content type, and the format and size the output loads as.
|
||||||
|
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.ContentType != "image/jxl" {
|
||||||
|
t.Errorf("ContentType = %q, want image/jxl", result.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := vips.NewImageFromBuffer(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load the output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer output.Close()
|
||||||
|
|
||||||
|
if output.Format() != vips.ImageTypeJXL {
|
||||||
|
t.Errorf("output format = %s, want jxl", vips.ImageTypes[output.Format()])
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.Width() != 100 || output.Height() != 75 {
|
||||||
|
t.Errorf("output size = %dx%d, want 100x75", output.Width(), output.Height())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLQuality verifies that the quality reaches the JPEG
|
||||||
|
// XL encoder: the same image comes out smaller at quality 30 than at 90.
|
||||||
|
func TestImageProcessor_JPEGXLQuality(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 400, 300)
|
||||||
|
outputBytes := make(map[int]int64)
|
||||||
|
|
||||||
|
for _, quality := range []int{30, 90} {
|
||||||
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
outputBytes[quality] = result.ContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputBytes[30] >= outputBytes[90] {
|
||||||
|
t.Errorf("%d bytes at quality 30, %d at 90, want fewer at 30",
|
||||||
|
outputBytes[30], outputBytes[90])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLDropsSourceEXIF verifies that none of the source's
|
||||||
|
// EXIF reaches a JPEG XL output. libvips 8.16 and later write an EXIF block of
|
||||||
|
// their own into JPEG XL (orientation, resolution, size, colour space and
|
||||||
|
// fixed defaults), and govips cannot ask them to leave it out, so the test
|
||||||
|
// looks for the source's fields rather than for no EXIF at all.
|
||||||
|
func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input, err := os.ReadFile("testdata/gps-exif.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
|
||||||
|
|
||||||
|
for _, field := range []string{
|
||||||
|
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
||||||
|
"exif-ifd2-DateTimeOriginal", "exif-ifd3-GPSLatitude",
|
||||||
|
"exif-ifd3-GPSLongitude",
|
||||||
|
} {
|
||||||
|
if value, found := exif[field]; found {
|
||||||
|
t.Errorf("output has %s: %s", field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLDropsSourceResolution verifies that the source's
|
||||||
|
// resolution does not reach the EXIF block libvips 8.16 and later write into
|
||||||
|
// JPEG XL. A JPEG XL image holds its resolution in that block alone, so the
|
||||||
|
// resolution the output loads with is the block's.
|
||||||
|
func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// dpi-300.jpg is a flat 8x8 grey image with a resolution of 300 dpi.
|
||||||
|
input, err := os.ReadFile("testdata/dpi-300.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||||
|
|
||||||
|
// libvips gives the resolution in pixels per millimetre.
|
||||||
|
xDPI := math.Round(output.ResX() * 25.4)
|
||||||
|
yDPI := math.Round(output.ResY() * 25.4)
|
||||||
|
|
||||||
|
if xDPI == 300 || yDPI == 300 {
|
||||||
|
t.Errorf("output resolution = %vx%v dpi, the source's", xDPI, yDPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL
|
||||||
|
// output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does
|
||||||
|
// for PNG.
|
||||||
|
func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// orientation-6.jpg is stored 16x8, red on the left and blue on the
|
||||||
|
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
|
||||||
|
// Upright it is 8x16, red on top and blue below.
|
||||||
|
input, err := os.ReadFile("testdata/orientation-6.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||||
|
|
||||||
|
if output.Width() != 8 || output.Height() != 16 {
|
||||||
|
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
||||||
|
}
|
||||||
|
|
||||||
|
top, err := output.GetPoint(4, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottom, err := output.GetPoint(4, 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if top[0] <= top[2] || bottom[2] <= bottom[0] {
|
||||||
|
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
|
||||||
|
top, bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLConvertsWideGamutToSRGB verifies that a JPEG XL
|
||||||
|
// output is converted to sRGB, as TestImageProcessor_ConvertsWideGamutToSRGB
|
||||||
|
// does for PNG. It does not check for an ICC profile, as libvips reports one
|
||||||
|
// for every JPEG XL image it loads.
|
||||||
|
func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
|
||||||
|
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
|
||||||
|
input, err := os.ReadFile("testdata/display-p3.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||||
|
|
||||||
|
pixel, err := output.GetPoint(4, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []float64{255, 0, 0}
|
||||||
|
for i := range want {
|
||||||
|
if math.Abs(pixel[i]-want[i]) > 5 {
|
||||||
|
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_JPEGXLFromCMYK verifies that a CMYK JPEG with no ICC
|
||||||
|
// profile can be served as JPEG XL, in sRGB.
|
||||||
|
func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// cmyk.jpg is a flat 8x8 CMYK image with no ICC profile, filled with
|
||||||
|
// full cyan and no magenta, yellow or black.
|
||||||
|
input, err := os.ReadFile("testdata/cmyk.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
||||||
|
|
||||||
|
if output.Bands() != 3 {
|
||||||
|
t.Fatalf("output has %d bands, want 3", output.Bands())
|
||||||
|
}
|
||||||
|
|
||||||
|
pixel, err := output.GetPoint(4, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cyan in sRGB: little red, much green and blue.
|
||||||
|
if pixel[0] > 50 || pixel[1] < 100 || pixel[2] < 200 {
|
||||||
|
t.Errorf("pixel = %v, want cyan", pixel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJXLDistance verifies the JPEG XL distance for a few qualities: 90 is
|
||||||
|
// distance 1, the encoder's own default, and 100 stays lossy.
|
||||||
|
func TestJXLDistance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
quality int
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{quality: 100, want: 0.1},
|
||||||
|
{quality: 90, want: 1},
|
||||||
|
{quality: 30, want: 6.4},
|
||||||
|
{quality: 20, want: 9.0667},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := jxlDistance(tt.quality)
|
||||||
|
if math.Abs(got-tt.want) > 0.0001 {
|
||||||
|
t.Errorf("jxlDistance(%d) = %v, want %v", tt.quality, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,14 +115,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 +132,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
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||||
|
// input, which is not a source format, is refused with
|
||||||
|
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
|
||||||
|
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
img, err := vips.NewImageFromBuffer(createTestPNG(t, 20, 20))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(img.Close)
|
||||||
|
|
||||||
|
tiff, _, err := img.ExportTiff(vips.NewTiffExportParams())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to save test TIFF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(
|
||||||
|
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal},
|
||||||
|
)
|
||||||
|
if result != nil {
|
||||||
|
t.Logf("Process() returned %s", result.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrUnsupportedInputFormat) {
|
||||||
|
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedInputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
|
||||||
|
// the six source formats, saved by the processor itself, is loaded and saved
|
||||||
|
// again in its own format (orig).
|
||||||
|
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
|
||||||
|
for _, format := range []Format{
|
||||||
|
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
|
||||||
|
} {
|
||||||
|
saved, err := proc.Process(t.Context(),
|
||||||
|
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("saving the test image as %s: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := io.ReadAll(saved.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
||||||
|
&Request{Format: FormatOriginal})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("orig of the %s image: %v", format, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("orig of the %s image: %s", format, result.ContentType)
|
||||||
|
|
||||||
|
if result.ContentType != FormatToMIME(format) {
|
||||||
|
t.Errorf("orig of the %s image is %s, want %s",
|
||||||
|
format, result.ContentType, FormatToMIME(format))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 352 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 799 B |
+186
-36
@@ -11,6 +11,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
@@ -30,22 +32,33 @@ const httpStatusOK = 200
|
|||||||
// variant not among them is served as before, reading its .meta file.
|
// variant not among them is served as before, reading its .meta file.
|
||||||
const metaCacheSize = 10000
|
const metaCacheSize = 10000
|
||||||
|
|
||||||
|
// cleanExpiredBatchSize is the most expired negative cache rows one
|
||||||
|
// statement of CleanExpired deletes. Each batch is a statement of its
|
||||||
|
// own, so a request waits behind one batch at most, however many rows
|
||||||
|
// have expired.
|
||||||
|
const cleanExpiredBatchSize = 1000
|
||||||
|
|
||||||
// CacheConfig holds cache configuration.
|
// CacheConfig holds cache configuration.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
StateDir string
|
StateDir string
|
||||||
CacheTTL time.Duration
|
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
|
|
||||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
// enforces. Zero means no limit is enforced (no eviction). The
|
// enforces. Zero means no limit is enforced (no eviction).
|
||||||
// config layer supplies the computed default when the operator
|
|
||||||
// omits cache_max_bytes.
|
|
||||||
MaxBytes int64
|
MaxBytes int64
|
||||||
|
|
||||||
|
// UseDefaultMaxBytes makes NewCache replace MaxBytes with the
|
||||||
|
// default limit: 75% of the sum of the space free on the filesystem
|
||||||
|
// holding the cache and the bytes the cache already holds, at least
|
||||||
|
// DefaultCacheMaxBytesFloor. The config layer sets this when the
|
||||||
|
// operator omits cache_max_bytes.
|
||||||
|
UseDefaultMaxBytes bool
|
||||||
|
|
||||||
// DisableDiskCache turns the disk cache off entirely: no cache
|
// DisableDiskCache turns the disk cache off entirely: no cache
|
||||||
// directories are created, lookups always miss, stores are
|
// directories are created, lookups always miss, stores are
|
||||||
// no-ops, and no eviction machinery runs. The config layer sets
|
// no-ops, and 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
|
||||||
@@ -74,6 +87,27 @@ type Cache struct {
|
|||||||
evictionDone chan struct{}
|
evictionDone chan struct{}
|
||||||
evictionCancel context.CancelFunc
|
evictionCancel context.CancelFunc
|
||||||
|
|
||||||
|
// The pending counts: hits, misses, upstream fetches and transforms
|
||||||
|
// whose write to the database missed its request's deadline, kept
|
||||||
|
// here until they are written by the goroutine that
|
||||||
|
// StartPendingCountWrites starts. As for eviction, the channels are
|
||||||
|
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
||||||
|
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
||||||
|
// set by StartPendingCountWrites, cancels its context, which tells it
|
||||||
|
// to finish.
|
||||||
|
pendingHits atomic.Int64
|
||||||
|
pendingMisses atomic.Int64
|
||||||
|
pendingUpstreamFetches atomic.Int64
|
||||||
|
pendingUpstreamFetchBytes atomic.Int64
|
||||||
|
pendingTransforms atomic.Int64
|
||||||
|
pendingCountsAdded chan struct{}
|
||||||
|
pendingCountsDone chan struct{}
|
||||||
|
pendingCountsCancel context.CancelFunc
|
||||||
|
|
||||||
|
// Held through each write of the pending counts, and by
|
||||||
|
// readHitAndMissCounts to read them
|
||||||
|
pendingCountsWriteMutex sync.Mutex
|
||||||
|
|
||||||
// metaCache holds the content types of the variants most recently
|
// metaCache holds the content types of the variants most recently
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
// stored or served, so a hit does not read the variant's .meta file.
|
||||||
// It never stands in for the variant file, which is always opened.
|
// It never stands in for the variant file, which is always opened.
|
||||||
@@ -91,10 +125,29 @@ type Cache struct {
|
|||||||
// deterministically pause inside that window to exercise
|
// deterministically pause inside that window to exercise
|
||||||
// concurrent stores against it; production code leaves it nil.
|
// concurrent stores against it; production code leaves it nil.
|
||||||
evictSourceBlobTestHook func(ContentHash)
|
evictSourceBlobTestHook func(ContentHash)
|
||||||
|
|
||||||
|
// reconciliationPageSize is the most rows one read of a content table
|
||||||
|
// returns in the reconciliation pass and in Stats. newCache sets it to
|
||||||
|
// defaultReconciliationPageSize; tests set it smaller.
|
||||||
|
reconciliationPageSize int
|
||||||
|
|
||||||
|
// reconciliationReadTestHook, when set, is called after each of those
|
||||||
|
// reads with the number of rows the read covered, so tests can check
|
||||||
|
// that no read covers more than one page; production code leaves it
|
||||||
|
// nil.
|
||||||
|
reconciliationReadTestHook func(rows int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a new cache instance.
|
// NewCache creates a new cache instance.
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||||
|
return newCache(db, config, defaultFreeSpaceProbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCache is NewCache with the free-space probe passed in, so tests
|
||||||
|
// can fake the free space the default limit is worked out from.
|
||||||
|
func newCache(
|
||||||
|
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
|
||||||
|
) (*Cache, error) {
|
||||||
log := config.Logger
|
log := config.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
@@ -114,6 +167,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: metaCache,
|
metaCache: metaCache,
|
||||||
contentLocks: newContentLock(),
|
contentLocks: newContentLock(),
|
||||||
|
|
||||||
|
pendingCountsAdded: make(chan struct{}, 1),
|
||||||
|
pendingCountsDone: make(chan struct{}),
|
||||||
|
|
||||||
|
reconciliationPageSize: defaultReconciliationPageSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
@@ -145,6 +203,20 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
c.variants = variants
|
c.variants = variants
|
||||||
c.srcMetadata = srcMetadata
|
c.srcMetadata = srcMetadata
|
||||||
|
|
||||||
|
if config.UseDefaultMaxBytes {
|
||||||
|
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config.MaxBytes = limit
|
||||||
|
|
||||||
|
log.Info("computed default cache size limit from free space and cache contents",
|
||||||
|
"cache_max_bytes", limit,
|
||||||
|
"cache_dir", filepath.Join(config.StateDir, "cache"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,35 +489,41 @@ func (c *Cache) GetSourceContent(
|
|||||||
return c.srcContent.LoadWithSize(contentHash)
|
return c.srcContent.LoadWithSize(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -455,7 +533,7 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
c.log.Warn("failed to count cache items for stats", "error", err)
|
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
stats.TotalSizeBytes, err = c.sumContentSizeBytes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("failed to sum cache size for stats", "error", err)
|
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||||
}
|
}
|
||||||
@@ -470,19 +548,30 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||||
// fetchBytes bytes, as IncrementUpstreamFetch does.
|
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
||||||
|
// methods, it writes to the database with ctx's deadline but not its
|
||||||
|
// cancellation, so an ended request is still counted but does not wait for
|
||||||
|
// the database past its deadline; a count not written by then becomes a
|
||||||
|
// pending count (see StartPendingCountWrites).
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||||
|
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
pendingCount := &c.pendingMisses
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
pendingCount = &c.pendingHits
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET hit_count = hit_count + 1,
|
SET hit_count = hit_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET miss_count = miss_count + 1,
|
SET miss_count = miss_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -490,7 +579,10 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
c.addPendingCount(pendingCount, 1)
|
||||||
|
case err != nil:
|
||||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,14 +596,22 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
SET upstream_fetch_count = upstream_fetch_count + 1,
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`, fetchBytes)
|
`, fetchBytes)
|
||||||
if err != nil {
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
c.addPendingCount(&c.pendingUpstreamFetches, 1)
|
||||||
|
c.addPendingCount(&c.pendingUpstreamFetchBytes, fetchBytes)
|
||||||
|
case err != nil:
|
||||||
c.log.Warn("failed to count upstream fetch",
|
c.log.Warn("failed to count upstream fetch",
|
||||||
"fetch_bytes", fetchBytes, "error", err)
|
"fetch_bytes", fetchBytes, "error", err)
|
||||||
}
|
}
|
||||||
@@ -519,25 +619,51 @@ func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
|||||||
|
|
||||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||||
_, err := c.db.ExecContext(ctx, `
|
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(countCtx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET transform_count = transform_count + 1,
|
SET transform_count = transform_count + 1,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
c.addPendingCount(&c.pendingTransforms, 1)
|
||||||
|
case err != nil:
|
||||||
c.log.Warn("failed to count transform", "error", err)
|
c.log.Warn("failed to count transform", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
||||||
|
// cache_stats row plus the pending hits and misses (see
|
||||||
|
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
|
||||||
|
// write of the pending counts falls between the two reads.
|
||||||
|
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
|
||||||
|
c.pendingCountsWriteMutex.Lock()
|
||||||
|
defer c.pendingCountsWriteMutex.Unlock()
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT hit_count, miss_count
|
||||||
|
FROM cache_stats WHERE id = 1
|
||||||
|
`).Scan(&stats.HitCount, &stats.MissCount)
|
||||||
|
|
||||||
|
stats.HitCount += c.pendingHits.Load()
|
||||||
|
stats.MissCount += c.pendingMisses.Load()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||||
// hold: it reads the content type from the variant's .meta file and
|
// hold: it reads the content type from the variant's .meta file and
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||||
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
||||||
// as one can between a store's writing of the variant file and of its
|
// as one can when the store's .meta write failed or an eviction removed
|
||||||
// .meta file, serves application/octet-stream and keeps nothing, so
|
// the file after the read opened the variant, serves
|
||||||
// metaCache only ever holds a type read from a .meta file or passed to
|
// application/octet-stream and keeps nothing, so metaCache only ever
|
||||||
// StoreVariant.
|
// holds a type read from a .meta file or passed to StoreVariant.
|
||||||
func (c *Cache) loadVariantWithMeta(
|
func (c *Cache) loadVariantWithMeta(
|
||||||
cacheKey VariantKey,
|
cacheKey VariantKey,
|
||||||
) (io.ReadCloser, int64, string, error) {
|
) (io.ReadCloser, int64, string, error) {
|
||||||
@@ -647,3 +773,27 @@ func (c *Cache) checkNegativeCache(
|
|||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
||||||
|
// rows past their expiry in one statement, and returns how many it
|
||||||
|
// deleted. DELETE ... LIMIT needs a SQLite build option, so the subquery
|
||||||
|
// picks the rows.
|
||||||
|
func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
|
||||||
|
result, err := c.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM negative_cache WHERE id IN (
|
||||||
|
SELECT id FROM negative_cache
|
||||||
|
WHERE expires_at < CURRENT_TIMESTAMP
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
`, cleanExpiredBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to clean negative cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count cleaned negative cache rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ func setupTestCache(t *testing.T) *Cache {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,7 +240,6 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
// Very short negative TTL for testing
|
// Very short negative TTL for testing
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
NegativeTTL: 1 * time.Millisecond,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
|
||||||
|
// cache content, then drops both content tables. UsageBytes must still
|
||||||
|
// report what the tables held, and an eviction pass under the limit must
|
||||||
|
// still succeed: neither may sum the tables.
|
||||||
|
func TestEvictionReadsTheUsageTotal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
kept := storeEvictionTestSource(t, cache, "total.example.com", "/kept.jpg",
|
||||||
|
bytes.Repeat([]byte{0x71}, 1000))
|
||||||
|
evicted := storeEvictionTestSource(t, cache, "total.example.com", "/evicted.jpg",
|
||||||
|
bytes.Repeat([]byte{0x72}, 700))
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0x73}, 500))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0x74}, 300))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||||
|
bytes.Repeat([]byte{0x75}, 200))
|
||||||
|
|
||||||
|
// pixa never changes a source's size, but a statement that does must
|
||||||
|
// change the total too.
|
||||||
|
_, err := cache.db.ExecContext(ctx,
|
||||||
|
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
|
||||||
|
string(kept))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to change the source's size: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.evictSourceBlob(ctx, evicted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evictSourceBlob failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.evictVariant(ctx, testVariantKeyTwo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evictVariant failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cache.db.ExecContext(ctx,
|
||||||
|
`DROP TABLE source_content; DROP TABLE variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to drop the content tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
t.Logf("UsageBytes() = %d, error = %v", usage, err)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes() error = %v, want nil: it read a content table", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 1200 {
|
||||||
|
t.Errorf("UsageBytes() = %d, want 1200 (900 + 300)", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.EvictToLimit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EvictToLimit() error = %v, want nil: it read a content table", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconciliationCorrectsTheUsageTotal sets the total cache usage to a
|
||||||
|
// wrong value and checks that a reconciliation pass, which sums both
|
||||||
|
// content tables, puts it right.
|
||||||
|
func TestReconciliationCorrectsTheUsageTotal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
storeEvictionTestSource(t, cache, "total.example.com", "/a.jpg",
|
||||||
|
bytes.Repeat([]byte{0x76}, 1000))
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0x77}, 500))
|
||||||
|
|
||||||
|
_, err := cache.db.ExecContext(ctx,
|
||||||
|
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set a wrong total: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.reconcileAccounting(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 1500 {
|
||||||
|
t.Errorf("UsageBytes() after reconciliation = %d, want 1500 (1000 + 500)",
|
||||||
|
usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsageTotalNotCorrectedFromAnOlderSum stores a variant after the
|
||||||
|
// change count was read, then offers a sum taken before the store as the
|
||||||
|
// correction: the total must keep the stored variant, as that sum may
|
||||||
|
// have missed it.
|
||||||
|
func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
var changeCount int64
|
||||||
|
|
||||||
|
err := cache.db.QueryRowContext(ctx,
|
||||||
|
`SELECT change_count FROM cache_usage WHERE id = 1`,
|
||||||
|
).Scan(&changeCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read the change count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
|
bytes.Repeat([]byte{0x78}, 500))
|
||||||
|
|
||||||
|
corrected, err := cache.correctUsageTotal(ctx, 0, changeCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("correctUsageTotal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if corrected {
|
||||||
|
t.Error("correctUsageTotal() = true, want false: content changed since the sum")
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 500 {
|
||||||
|
t.Errorf("UsageBytes() = %d, want 500", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconciliationReadsAPageAtATime stores five source images and five
|
||||||
|
// variants, sets the page size to two rows and runs a reconciliation
|
||||||
|
// pass. No read the pass makes of a content table, to check its rows or
|
||||||
|
// to sum them, may cover more than two rows, so a request's query waits
|
||||||
|
// for one page at most. Between them the reads must still cover every
|
||||||
|
// row of both tables twice, once to check it and once to sum it, and the
|
||||||
|
// sum must put a wrong total right.
|
||||||
|
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
const pageSize = 2
|
||||||
|
|
||||||
|
cache.reconciliationPageSize = pageSize
|
||||||
|
|
||||||
|
// Sources of 100 bytes and variants of 10, five of each.
|
||||||
|
for i := range 5 {
|
||||||
|
content := []byte(strconv.Itoa(i))
|
||||||
|
|
||||||
|
storeEvictionTestSource(t, cache, "pages.example.com",
|
||||||
|
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
|
||||||
|
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)),
|
||||||
|
bytes.Repeat(content, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cache.db.ExecContext(ctx,
|
||||||
|
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set a wrong total: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowsPerRead []int
|
||||||
|
|
||||||
|
cache.reconciliationReadTestHook = func(rows int) {
|
||||||
|
rowsPerRead = append(rowsPerRead, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.reconcileAccounting(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("rows covered by each read, in order: %v", rowsPerRead)
|
||||||
|
|
||||||
|
coveredRows := 0
|
||||||
|
|
||||||
|
for _, rows := range rowsPerRead {
|
||||||
|
if rows > pageSize {
|
||||||
|
t.Errorf("a read covered %d rows, want at most %d", rows, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
coveredRows += rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ten rows, each read once to check it and once to sum it.
|
||||||
|
if coveredRows != 20 {
|
||||||
|
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 550 {
|
||||||
|
t.Errorf("UsageBytes() after reconciliation = %d, want 550 (5*100 + 5*10)",
|
||||||
|
usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceEvictionCandidatesComeFromTheIndex checks that the query
|
||||||
|
// choosing source images to evict reads source_content in last access
|
||||||
|
// order from its index, instead of reading and sorting the whole table.
|
||||||
|
func TestSourceEvictionCandidatesComeFromTheIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
rows, err := cache.db.QueryContext(t.Context(),
|
||||||
|
`EXPLAIN QUERY PLAN `+sourceCandidatesQuery, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EXPLAIN QUERY PLAN failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var plan []string
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id, parent, unused int
|
||||||
|
|
||||||
|
var detail string
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &parent, &unused, &detail)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to scan the query plan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = append(plan, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query plan iteration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("query plan: %q", plan)
|
||||||
|
|
||||||
|
if !strings.Contains(strings.Join(plan, "\n"), "idx_source_content_last_accessed") {
|
||||||
|
t.Errorf("the source candidate query does not use "+
|
||||||
|
"idx_source_content_last_accessed; plan: %q", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheMaxBytesFloor is the minimum computed default for the
|
||||||
|
// cache_max_bytes setting: 500 MiB. The floor applies only to the
|
||||||
|
// computed default (when the key is omitted from the configuration),
|
||||||
|
// never to explicitly configured values.
|
||||||
|
const DefaultCacheMaxBytesFloor int64 = 524288000
|
||||||
|
|
||||||
|
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
|
||||||
|
// the 75% share used for the computed default limit as integer
|
||||||
|
// arithmetic (dividing before multiplying avoids overflow).
|
||||||
|
const (
|
||||||
|
freeSpaceFractionNumerator uint64 = 3
|
||||||
|
freeSpaceFractionDenominator uint64 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNegativeBlockSize = errors.New("statfs reported negative block size")
|
||||||
|
|
||||||
|
// FreeSpaceProbeFunc reports the number of free bytes available on the
|
||||||
|
// filesystem containing path. It is a function type so tests can
|
||||||
|
// inject a fake probe instead of depending on the host disk.
|
||||||
|
type FreeSpaceProbeFunc func(path string) (uint64, error)
|
||||||
|
|
||||||
|
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
|
||||||
|
// the given path, as available to unprivileged processes.
|
||||||
|
func defaultFreeSpaceProbe(path string) (uint64, error) {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
|
||||||
|
err := syscall.Statfs(path, &stat)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Bsize < 0 {
|
||||||
|
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
|
||||||
|
return stat.Bavail * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeDefaultMaxBytes returns the default cache size limit: 75% of
|
||||||
|
// the sum of the free bytes probe reports for <state_dir>/cache/ and
|
||||||
|
// the bytes the cache already holds, with a floor of
|
||||||
|
// DefaultCacheMaxBytesFloor. Counting what the cache holds keeps the
|
||||||
|
// limit from shrinking as the cache fills.
|
||||||
|
func (c *Cache) computeDefaultMaxBytes(
|
||||||
|
ctx context.Context, probe FreeSpaceProbeFunc,
|
||||||
|
) (int64, error) {
|
||||||
|
cacheDir := filepath.Join(c.config.StateDir, "cache")
|
||||||
|
|
||||||
|
freeBytes, err := probe(cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"default cache_max_bytes: cannot determine free space for %q: %w",
|
||||||
|
cacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usedBytes, err := c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
|
||||||
|
//nolint:gosec // G115: UsageBytes returns the total cache usage, never negative
|
||||||
|
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
|
||||||
|
|
||||||
|
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||||
|
computed = min(computed, math.MaxInt64)
|
||||||
|
|
||||||
|
limit := int64(computed)
|
||||||
|
limit = max(limit, DefaultCacheMaxBytesFloor)
|
||||||
|
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Static errors returned by the stub free-space probes below.
|
||||||
|
var (
|
||||||
|
errTestStatfsFailed = errors.New("statfs failed")
|
||||||
|
errTestProbeNotExpected = errors.New("probe must not be called")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestComputeDefaultMaxBytesCountsWhatTheCacheHolds verifies that the
|
||||||
|
// default limit is 75% of the free space plus what the cache already
|
||||||
|
// holds, so a cache filled to its limit keeps that limit across a
|
||||||
|
// restart instead of shrinking to 75% of the space left free.
|
||||||
|
func TestComputeDefaultMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
// Empty cache, 4 GiB free -> 3 GiB default.
|
||||||
|
got, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return 4294967296, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("default for an empty cache with 4 GiB free: %d", got)
|
||||||
|
|
||||||
|
if got != 3221225472 {
|
||||||
|
t.Errorf("default for an empty cache = %d, want 3221225472 (75%% of 4 GiB)",
|
||||||
|
got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache now holds those 3 GiB, which leaves 1 GiB free.
|
||||||
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
string(testVariantKeyOne), 3221225472, testContentTypeWebP,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return 1073741824, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("default for a cache holding 3 GiB with 1 GiB free: %d", got)
|
||||||
|
|
||||||
|
if got != 3221225472 {
|
||||||
|
t.Errorf("default for a cache holding 3 GiB with 1 GiB free = %d, "+
|
||||||
|
"want 3221225472 (75%% of 1 GiB + 3 GiB)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultMaxBytesAppliesFloor verifies that when 75% of the
|
||||||
|
// free space plus what the cache holds is below 500 MiB, the default
|
||||||
|
// is floored at DefaultCacheMaxBytesFloor.
|
||||||
|
func TestComputeDefaultMaxBytesAppliesFloor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
freeBytes uint64
|
||||||
|
}{
|
||||||
|
{name: "100 MiB free", freeBytes: 104857600},
|
||||||
|
{name: "zero free", freeBytes: 0},
|
||||||
|
{name: "just below floor threshold", freeBytes: 699050665},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
got, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return tc.freeBytes, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != DefaultCacheMaxBytesFloor {
|
||||||
|
t.Errorf("computeDefaultMaxBytes = %d, want floor %d",
|
||||||
|
got, DefaultCacheMaxBytesFloor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultMaxBytesPropagatesProbeError verifies that a
|
||||||
|
// failing free-space probe produces an error naming cache_max_bytes,
|
||||||
|
// instead of a silently wrong default.
|
||||||
|
func TestComputeDefaultMaxBytesPropagatesProbeError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
_, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return 0, errTestStatfsFailed })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("probe failure must produce an error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "cache_max_bytes") {
|
||||||
|
t.Errorf("error %q does not name cache_max_bytes", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCacheComputesDefaultMaxBytesWhenAsked verifies that with
|
||||||
|
// UseDefaultMaxBytes set, the cache's limit becomes the computed
|
||||||
|
// default, and that the probe is pointed at <state_dir>/cache/, which
|
||||||
|
// must be created first so statfs measures the right filesystem.
|
||||||
|
func TestNewCacheComputesDefaultMaxBytesWhenAsked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
wantCacheDir := filepath.Join(stateDir, "cache")
|
||||||
|
|
||||||
|
var probedPath string
|
||||||
|
|
||||||
|
// 4 GiB free -> 3 GiB default.
|
||||||
|
probe := func(path string) (uint64, error) {
|
||||||
|
probedPath = path
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
||||||
|
path, info, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 4294967296, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := newCache(evictionTestDB(t), CacheConfig{
|
||||||
|
StateDir: stateDir,
|
||||||
|
UseDefaultMaxBytes: true,
|
||||||
|
}, probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCache returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.config.MaxBytes != 3221225472 {
|
||||||
|
t.Errorf("MaxBytes = %d, want computed default 3221225472",
|
||||||
|
cache.config.MaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if probedPath != wantCacheDir {
|
||||||
|
t.Errorf("free space probed at %q, want cache directory %q",
|
||||||
|
probedPath, wantCacheDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCacheKeepsExplicitMaxBytes verifies that without
|
||||||
|
// UseDefaultMaxBytes the cache keeps MaxBytes exactly as given and
|
||||||
|
// never consults the free-space probe.
|
||||||
|
func TestNewCacheKeepsExplicitMaxBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
probe := func(string) (uint64, error) {
|
||||||
|
t.Error("free-space probe must not be consulted for explicit values")
|
||||||
|
|
||||||
|
return 0, errTestProbeNotExpected
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := newCache(evictionTestDB(t), CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
MaxBytes: 1024,
|
||||||
|
}, probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCache returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.config.MaxBytes != 1024 {
|
||||||
|
t.Errorf("MaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
||||||
|
cache.config.MaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ 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/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -435,6 +436,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, FitCover))
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
|
||||||
|
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
t.Logf("first request: Get() error = %v", (<-first).err)
|
||||||
|
|
||||||
|
got := <-waiter
|
||||||
|
t.Logf("waiting request: Get() error = %v", got.err)
|
||||||
|
|
||||||
|
if !errors.Is(got.err, ErrProcessingTimedOut) {
|
||||||
|
t.Errorf("waiting request: Get() error = %v, want %v",
|
||||||
|
got.err, ErrProcessingTimedOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// panickingFetcher panics on every fetch.
|
// panickingFetcher panics on every fetch.
|
||||||
type panickingFetcher struct{}
|
type panickingFetcher struct{}
|
||||||
|
|
||||||
|
|||||||
+353
-118
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
@@ -21,6 +22,12 @@ const DefaultEvictionInterval = 5 * time.Minute
|
|||||||
// and source blobs) one eviction pass fetches from the database.
|
// and source blobs) one eviction pass fetches from the database.
|
||||||
const evictionBatchSize = 100
|
const evictionBatchSize = 100
|
||||||
|
|
||||||
|
// defaultReconciliationPageSize is the most rows one read of the
|
||||||
|
// reconciliation pass returns, unless a test sets
|
||||||
|
// Cache.reconciliationPageSize smaller. Each read is a query of its own,
|
||||||
|
// so a request waits for one page at most, however large the cache is.
|
||||||
|
const defaultReconciliationPageSize = 1000
|
||||||
|
|
||||||
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
||||||
// crashed write) must be before reconciliation removes it. Fresh temp
|
// crashed write) must be before reconciliation removes it. Fresh temp
|
||||||
// files may still belong to an in-flight store.
|
// files may still belong to an in-flight store.
|
||||||
@@ -45,7 +52,9 @@ const fallbackContentType = "application/octet-stream"
|
|||||||
|
|
||||||
// UsageBytes returns the total number of bytes of cache content
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
// tracked in the database (source content blobs plus processed
|
// tracked in the database (source content blobs plus processed
|
||||||
// variants). It never scans the cache directories.
|
// variants). It reads the total the database keeps up to date as rows
|
||||||
|
// are added and removed, so it neither scans the cache directories nor
|
||||||
|
// sums the tables.
|
||||||
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -53,12 +62,11 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx,
|
||||||
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
`SELECT total_size_bytes FROM cache_usage WHERE id = 1`,
|
||||||
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
).Scan(&total)
|
||||||
`).Scan(&total)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
return 0, fmt.Errorf("failed to read cache usage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return total, nil
|
return total, nil
|
||||||
@@ -236,16 +244,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 content_hash, size_bytes, last_accessed_at
|
||||||
|
FROM source_content
|
||||||
|
ORDER BY last_accessed_at ASC, content_hash ASC
|
||||||
|
LIMIT ?`
|
||||||
|
|
||||||
|
// sourceCandidates returns the least recently used source blobs.
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -427,14 +438,16 @@ 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
|
// reconciles the database accounting with the cache directories and
|
||||||
// startup and again on every periodic tick thereafter, and evicts to
|
// removes the expired negative cache rows at startup and again on every
|
||||||
// the configured limit on the given periodic interval and on
|
// periodic tick thereafter, and evicts to the configured limit on the
|
||||||
// write-pressure notifications. It is a no-op on a disabled cache or
|
// given periodic interval and on write-pressure notifications. On a
|
||||||
// when already started. The goroutine outlives the caller, so it runs
|
// disabled cache it only removes the expired negative cache rows, as
|
||||||
// with its own context, which StopEviction cancels.
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +483,7 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
|||||||
defer close(c.evictionDone)
|
defer close(c.evictionDone)
|
||||||
|
|
||||||
c.runReconciliationPass(ctx)
|
c.runReconciliationPass(ctx)
|
||||||
|
c.runCleanExpired(ctx)
|
||||||
c.runEvictionPass(ctx)
|
c.runEvictionPass(ctx)
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
@@ -490,6 +504,7 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
|||||||
// unaccounted for to one interval, on a process that is
|
// unaccounted for to one interval, on a process that is
|
||||||
// already running this loop regardless.
|
// already running this loop regardless.
|
||||||
c.runReconciliationPass(ctx)
|
c.runReconciliationPass(ctx)
|
||||||
|
c.runCleanExpired(ctx)
|
||||||
case <-c.evictionPressure:
|
case <-c.evictionPressure:
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +540,20 @@ func (c *Cache) runReconciliationPass(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runCleanExpired runs CleanExpired, logging a failure instead of
|
||||||
|
// propagating it (the loop must keep running). It does nothing once ctx
|
||||||
|
// is cancelled.
|
||||||
|
func (c *Cache) runCleanExpired(ctx context.Context) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.CleanExpired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("negative cache cleanup failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reconcileAccounting synchronizes the database size accounting with
|
// 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
|
// again on every periodic eviction tick thereafter, off the request
|
||||||
@@ -532,11 +561,14 @@ func (c *Cache) runReconciliationPass(ctx context.Context) {
|
|||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops accounting rows whose
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
// files are missing, removes source blob files the database does not
|
// files are missing, removes source blob files the database does not
|
||||||
// know (and rows whose files are gone), and sweeps stale temp files
|
// know (and rows whose files are gone), removes source and variant files
|
||||||
// left behind by crashed writes. Running it periodically, not just
|
// that are empty or not the size their rows record, sweeps stale temp
|
||||||
// once, bounds how long such drift can accumulate unaccounted for on a
|
// files left behind by crashed writes, and last checks the total cache
|
||||||
// long-running process to one eviction interval. Once ctx is cancelled,
|
// usage against the tables. It 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 one eviction
|
||||||
|
// interval. Once ctx is cancelled, it stops at the next file or row and
|
||||||
|
// returns ctx's error.
|
||||||
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil
|
return nil
|
||||||
@@ -562,11 +594,12 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return c.reconcileUsageTotal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileVariantFiles walks the variant storage directory, adopting
|
// reconcileVariantFiles walks the variant storage directory, checking
|
||||||
// files without accounting rows and sweeping stale temp files.
|
// each variant file against its accounting row (reconcileVariantFile)
|
||||||
|
// and sweeping stale temp files.
|
||||||
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
return filepath.WalkDir(
|
return filepath.WalkDir(
|
||||||
c.variants.baseDir,
|
c.variants.baseDir,
|
||||||
@@ -591,38 +624,55 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
return c.reconcileVariantFile(ctx, path, entry, VariantKey(name))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptVariantFile inserts an accounting row for a variant file that
|
// reconcileVariantFile checks one variant file against its accounting
|
||||||
// has none, using the file's size and modification time.
|
// row. A file without a row is adopted into the accounting, unless it is
|
||||||
func (c *Cache) adoptVariantFile(
|
// empty. A file that is empty, or not the size its row records, as a
|
||||||
|
// crash or a restored snapshot can leave it, is removed with its row and
|
||||||
|
// a warning naming it, so the variant is converted again.
|
||||||
|
func (c *Cache) reconcileVariantFile(
|
||||||
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var recordedSize int64
|
||||||
|
|
||||||
|
err = c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
||||||
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
||||||
|
return c.adoptVariantFile(ctx, path, info, cacheKey)
|
||||||
|
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||||
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is empty, or not the size its row records
|
||||||
|
c.log.Warn("removing damaged cached variant file", "path", path,
|
||||||
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
|
return c.evictVariant(ctx, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
|
// has none, using the file's size and modification time.
|
||||||
|
func (c *Cache) adoptVariantFile(
|
||||||
|
ctx context.Context, path string, info fs.FileInfo, cacheKey VariantKey,
|
||||||
|
) error {
|
||||||
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
contentType := c.variantContentTypeFromSidecar(path)
|
contentType := c.variantContentTypeFromSidecar(path)
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content
|
INSERT INTO variant_content
|
||||||
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -657,46 +707,63 @@ func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
|||||||
// reconcileVariantRows drops accounting rows whose variant files are
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
// missing, so the database never references deleted content.
|
// missing, so the database never references deleted content.
|
||||||
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
keys, err := c.allVariantKeys(ctx)
|
var after VariantKey
|
||||||
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 VariantKey,
|
||||||
|
) ([]VariantKey, error) {
|
||||||
|
return queryStringColumn[VariantKey](ctx, c.db, `
|
||||||
|
SELECT cache_key FROM variant_content
|
||||||
|
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
|
||||||
|
`, "variant keys", "variant key", string(after), c.reconciliationPageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryStringColumn runs a single-column query and returns the column
|
// queryStringColumn runs a single-column query with args and returns the
|
||||||
// values as T. plural names the set for the query and scan failure
|
// column values as T. plural names the set for the query and scan
|
||||||
// messages; singular names one row for the scan and iteration failure
|
// failure messages; singular names one row for the scan and iteration
|
||||||
// messages.
|
// failure messages.
|
||||||
func queryStringColumn[T ~string](
|
func queryStringColumn[T ~string](
|
||||||
ctx context.Context, db *sql.DB, query, plural, singular string,
|
ctx context.Context, db *sql.DB, query, plural, singular string, args ...any,
|
||||||
) ([]T, error) {
|
) ([]T, error) {
|
||||||
rows, err := db.QueryContext(ctx, query)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
||||||
}
|
}
|
||||||
@@ -724,10 +791,9 @@ 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 filepath.WalkDir(
|
||||||
c.srcContent.baseDir,
|
c.srcContent.baseDir,
|
||||||
@@ -748,31 +814,55 @@ func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
return c.reconcileSourceFile(ctx, path, entry, ContentHash(name))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileSourceFile checks one source blob file against its
|
||||||
|
// source_content row. A file without a row is removed: it is
|
||||||
|
// unreachable, as source lookups always go through source_metadata. A
|
||||||
|
// file that is empty, or not the size its row records, as a crash or a
|
||||||
|
// restored snapshot can leave it, is removed with its rows and a warning
|
||||||
|
// naming it, so the source is fetched again.
|
||||||
|
func (c *Cache) reconcileSourceFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, contentHash ContentHash,
|
||||||
|
) error {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat source content file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordedSize int64
|
||||||
|
|
||||||
|
err = c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT size_bytes FROM source_content WHERE content_hash = ?`,
|
||||||
|
string(contentHash),
|
||||||
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return c.removeUntrackedSourceFile(ctx, path, contentHash)
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is empty, or not the size its row records
|
||||||
|
c.log.Warn("removing damaged cached source file", "path", path,
|
||||||
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
|
return c.evictSourceBlob(ctx, contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
// removeUntrackedSourceFile deletes a source blob file that has no
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
// source_content row. Any source_metadata rows referencing the hash
|
// source_content row. Any source_metadata rows referencing the hash
|
||||||
// are removed first so no row ever points at a deleted file.
|
// are removed first so no row ever points at a deleted file.
|
||||||
func (c *Cache) removeUntrackedSourceFile(
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
ctx context.Context, path string, contentHash ContentHash,
|
ctx context.Context, path string, contentHash ContentHash,
|
||||||
) error {
|
) error {
|
||||||
var rowExists int
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
|
||||||
).Scan(&rowExists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check source content row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowExists > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
||||||
@@ -791,38 +881,183 @@ func (c *Cache) removeUntrackedSourceFile(
|
|||||||
// reconcileSourceRows removes source_content rows (and their metadata
|
// reconcileSourceRows removes source_content rows (and their metadata
|
||||||
// references and sidecars) whose blob files are missing on disk.
|
// references and sidecars) whose blob files are missing on disk.
|
||||||
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
||||||
hashes, err := c.allSourceContentHashes(ctx)
|
var after ContentHash
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range hashes {
|
for {
|
||||||
if ctx.Err() != nil {
|
hashes, err := c.sourceContentHashesAfter(ctx, after)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
if c.reconciliationReadTestHook != nil {
|
||||||
|
c.reconciliationReadTestHook(len(hashes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hash := range hashes {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.srcContent.Exists(hash) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blob file is already gone; evictSourceBlob removes the
|
||||||
|
// rows and sidecars and tolerates the missing file.
|
||||||
|
err := c.evictSourceBlob(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
after = hashes[len(hashes)-1]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceContentHashesAfter returns, in order, up to
|
||||||
|
// c.reconciliationPageSize tracked source content hashes that sort after
|
||||||
|
// the given one.
|
||||||
|
func (c *Cache) sourceContentHashesAfter(
|
||||||
|
ctx context.Context, after ContentHash,
|
||||||
|
) ([]ContentHash, error) {
|
||||||
|
return queryStringColumn[ContentHash](ctx, c.db, `
|
||||||
|
SELECT content_hash FROM source_content
|
||||||
|
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
||||||
|
`, "source content hashes", "content hash", string(after),
|
||||||
|
c.reconciliationPageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of these queries sums size_bytes over the next page of rows of
|
||||||
|
// one content table, the rows that sort after a key, and returns the
|
||||||
|
// page's last key, the sum and the number of rows in the page. Past the
|
||||||
|
// last row the page has no rows and the key is NULL.
|
||||||
|
const (
|
||||||
|
sourceSizePageQuery = `
|
||||||
|
SELECT MAX(content_hash), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
|
FROM (
|
||||||
|
SELECT content_hash, size_bytes FROM source_content
|
||||||
|
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
||||||
|
)`
|
||||||
|
variantSizePageQuery = `
|
||||||
|
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
|
FROM (
|
||||||
|
SELECT cache_key, size_bytes FROM variant_content
|
||||||
|
WHERE cache_key > ? ORDER BY cache_key LIMIT ?
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// sumContentSizeBytes sums size_bytes over both content tables, a page
|
||||||
|
// of rows per query.
|
||||||
|
func (c *Cache) sumContentSizeBytes(ctx context.Context) (int64, error) {
|
||||||
|
sourceBytes, err := c.sumSizeBytesInPages(ctx, sourceSizePageQuery)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
variantBytes, err := c.sumSizeBytesInPages(ctx, variantSizePageQuery)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceBytes + variantBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumSizeBytesInPages runs pageQuery, one of the size page queries
|
||||||
|
// above, from the first page to the last and adds up the page sums.
|
||||||
|
func (c *Cache) sumSizeBytesInPages(
|
||||||
|
ctx context.Context, pageQuery string,
|
||||||
|
) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
after := ""
|
||||||
|
|
||||||
|
for {
|
||||||
|
var lastKey sql.NullString
|
||||||
|
|
||||||
|
var pageBytes int64
|
||||||
|
|
||||||
|
var pageRows int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, pageQuery, after, c.reconciliationPageSize).
|
||||||
|
Scan(&lastKey, &pageBytes, &pageRows)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to sum cache content sizes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.reconciliationReadTestHook != nil {
|
||||||
|
c.reconciliationReadTestHook(pageRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pageRows == 0 {
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total += pageBytes
|
||||||
|
after = lastKey.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileUsageTotal checks the total cache usage the database keeps
|
||||||
|
// against size_bytes summed over both content tables, and corrects the
|
||||||
|
// total when they differ.
|
||||||
|
func (c *Cache) reconcileUsageTotal(ctx context.Context) error {
|
||||||
|
var totalBytes, changeCount int64
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT total_size_bytes, change_count FROM cache_usage WHERE id = 1`,
|
||||||
|
).Scan(&totalBytes, &changeCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read cache usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sumBytes, err := c.sumContentSizeBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if sumBytes == totalBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
corrected, err := c.correctUsageTotal(ctx, sumBytes, changeCount)
|
||||||
|
if err != nil || !corrected {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Warn("corrected total cache usage to the sum of the content tables",
|
||||||
|
"previous_usage_bytes", totalBytes, "usage_bytes", sumBytes)
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
// backwards compatibility with existing fixtures.
|
// backwards compatibility with existing fixtures.
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
MaxBytes: maxBytes,
|
MaxBytes: maxBytes,
|
||||||
DisableDiskCache: maxBytes == 0,
|
DisableDiskCache: maxBytes == 0,
|
||||||
@@ -681,6 +680,11 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvictionRunsOnPeriodicSchedule writes three variant files straight
|
||||||
|
// to disk, bypassing StoreVariant, so they have no accounting rows and no
|
||||||
|
// write-pressure notification fires. Only a periodic reconciliation pass
|
||||||
|
// can then adopt them, and only the eviction pass that follows it can
|
||||||
|
// evict them.
|
||||||
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -688,13 +692,29 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
// Start the evictor while the cache is empty, then create tracked
|
// Hold the test database's only connection, so the startup pass
|
||||||
// over-limit state WITHOUT going through the store methods, so no
|
// waits for it after walking the still empty variant directory: the
|
||||||
// write-pressure notification fires and only the periodic ticker
|
// files written while it waits are first seen by a periodic pass.
|
||||||
// can trigger eviction.
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
cache.StartEviction(100 * time.Millisecond)
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for cache.db.Stats().WaitCount == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the startup pass never waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
@@ -703,25 +723,43 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
_, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store variant file: %v", err)
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = cache.db.ExecContext(t.Context(),
|
_ = conn.Close()
|
||||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
|
||||||
VALUES (?, ?, ?)`,
|
// Only one of the 1000-byte files fits under the limit: wait until
|
||||||
string(key), len(content), "image/webp",
|
// the evictor has removed the other two.
|
||||||
)
|
stored := len(keys)
|
||||||
if err != nil {
|
deadline = time.Now().Add(5 * time.Second)
|
||||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
|
||||||
|
for stored > 1 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if cache.variants.Exists(key) {
|
||||||
|
stored++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
if stored > 1 {
|
||||||
|
t.Fatalf("periodic schedule did not trigger eviction: %d of %d "+
|
||||||
|
"variant files still on disk, want at most 1", stored, len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if usage > limit {
|
if usage > limit {
|
||||||
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
usage, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
@@ -808,15 +846,28 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
const interval = 100 * time.Millisecond
|
// Hold the test database's only connection, so the startup pass
|
||||||
|
// waits for it after walking the still empty variant directory: the
|
||||||
|
// file written while it waits is first seen by a periodic pass.
|
||||||
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
cache.StartEviction(interval)
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
// Let startup reconciliation run and settle on an empty cache
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
// before introducing the untracked file, so the adoption we assert
|
|
||||||
// below can only be the work of a later, periodic pass.
|
for cache.db.Stats().WaitCount == 0 {
|
||||||
time.Sleep(3 * interval)
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the startup pass never waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate a variant whose accounting insert failed after the
|
// Simulate a variant whose accounting insert failed after the
|
||||||
// process was already running and serving requests: the content
|
// process was already running and serving requests: the content
|
||||||
@@ -825,14 +876,16 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
// insert had failed and only the file write had succeeded.
|
// insert had failed and only the file write had succeeded.
|
||||||
untracked := bytes.Repeat([]byte{0x41}, 900)
|
untracked := bytes.Repeat([]byte{0x41}, 900)
|
||||||
|
|
||||||
_, err := cache.variants.Store(
|
_, err = cache.variants.Store(
|
||||||
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
_ = conn.Close()
|
||||||
|
|
||||||
|
deadline = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
var usage int64
|
var usage int64
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ const (
|
|||||||
FormatPNG ImageFormat = "png"
|
FormatPNG ImageFormat = "png"
|
||||||
FormatWebP ImageFormat = "webp"
|
FormatWebP ImageFormat = "webp"
|
||||||
FormatAVIF ImageFormat = "avif"
|
FormatAVIF ImageFormat = "avif"
|
||||||
|
FormatJXL ImageFormat = "jxl"
|
||||||
FormatGIF ImageFormat = "gif"
|
FormatGIF ImageFormat = "gif"
|
||||||
|
|
||||||
|
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
|
||||||
|
// request from its Accept header once the URL's signature or token has
|
||||||
|
// been checked; it is never processed or cached as itself.
|
||||||
|
FormatAuto ImageFormat = "auto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Size represents requested image dimensions
|
// Size represents requested image dimensions
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ import "time"
|
|||||||
|
|
||||||
// CacheConfig defaults.
|
// CacheConfig defaults.
|
||||||
const (
|
const (
|
||||||
DefaultCacheTTL = 24 * time.Hour
|
|
||||||
DefaultNegativeTTL = 5 * time.Minute
|
DefaultNegativeTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package imgcache
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -15,7 +16,6 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +63,6 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,6 +95,307 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ServesCachedVariantOfNegativeCachedURL caches a variant of
|
||||||
|
// the test photo, then keeps a failed fetch of the photo's URL in the negative
|
||||||
|
// cache, as a failed fetch for another variant does once the source has been
|
||||||
|
// evicted. The cached variant is still served, and a variant not in the cache
|
||||||
|
// is made from the cached source. Once the source is evicted, a variant not in
|
||||||
|
// the cache needs a fetch and is refused.
|
||||||
|
func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
err = svc.cache.StoreNegative(t.Context(), photoVariant(fixtures, 85, FitCover),
|
||||||
|
httpStatusBadGateway, "upstream answered 503")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreNegative() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
if resp.CacheStatus != CacheHit {
|
||||||
|
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||||
|
resp.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
|
||||||
|
t.Logf("Get() of a variant made from the cached source: error = %v", err)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of a variant made from the cached source error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
||||||
|
photoVariant(fixtures, 85, FitCover))
|
||||||
|
if err != nil || contentHash == "" {
|
||||||
|
t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svc.cache.evictSourceBlob(t.Context(), contentHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evictSourceBlob() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, FitCover))
|
||||||
|
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrNegativeCached) {
|
||||||
|
t.Errorf("Get() of a variant whose source was evicted error = %v, want %v",
|
||||||
|
err, ErrNegativeCached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictionLoopRemovesExpiredNegativeCacheRows stores a failed fetch
|
||||||
|
// that expires a second later and starts the eviction loop with a short
|
||||||
|
// interval: a later tick must remove its row although its URL is never
|
||||||
|
// requested again, and must keep a row that has not expired. It runs
|
||||||
|
// with the disk cache enabled and disabled, as the negative cache is
|
||||||
|
// used either way.
|
||||||
|
func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
disableDiskCache bool
|
||||||
|
}{
|
||||||
|
{name: "disk cache enabled", disableDiskCache: false},
|
||||||
|
{name: "disk cache disabled", disableDiskCache: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, err := NewCache(evictionTestDB(t), CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
NegativeTTL: time.Second,
|
||||||
|
DisableDiskCache: tt.disableDiskCache,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expired := &ImageRequest{
|
||||||
|
SourceHost: testHostExample, SourcePath: "/gone.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.StoreNegative(t.Context(), expired, 404, "not found")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreNegative failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const notExpiredPath = "/later.jpg"
|
||||||
|
|
||||||
|
_, err = cache.db.ExecContext(t.Context(), `
|
||||||
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, status_code, expires_at)
|
||||||
|
VALUES (?, ?, 404, datetime('now', '+1 hour'))
|
||||||
|
`, testHostExample, notExpiredPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert a row that has not expired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
|
for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 {
|
||||||
|
if time.Since(started) > 10*time.Second {
|
||||||
|
t.Fatal("the expired row is still in negative_cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("the expired row was removed %s after the loop started",
|
||||||
|
time.Since(started))
|
||||||
|
|
||||||
|
if negativeCacheRowCount(t, cache, notExpiredPath) != 1 {
|
||||||
|
t.Error("the row that has not expired was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// negativeCacheRowCount returns how many negative_cache rows hold
|
||||||
|
// sourcePath.
|
||||||
|
func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := cache.db.QueryRowContext(tb.Context(),
|
||||||
|
`SELECT COUNT(*) FROM negative_cache WHERE source_path = ?`,
|
||||||
|
sourcePath,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("failed to count negative_cache rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanExpiredDeletesInBatches stores one expired negative cache row
|
||||||
|
// more than one batch holds, and one row that has not expired. One
|
||||||
|
// statement must delete one batch and no more, so a request waits behind
|
||||||
|
// one batch at most; CleanExpired must then remove the expired row left
|
||||||
|
// over and keep the row that has not expired.
|
||||||
|
func TestCleanExpiredDeletesInBatches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1)
|
||||||
|
|
||||||
|
const notExpiredPath = "/later.jpg"
|
||||||
|
|
||||||
|
_, err = cache.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, status_code, expires_at)
|
||||||
|
VALUES (?, ?, 404, datetime('now', '+1 hour'))
|
||||||
|
`, testHostExample, notExpiredPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert a row that has not expired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := cache.cleanExpiredBatch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanExpiredBatch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("one statement deleted %d of %d expired rows",
|
||||||
|
deleted, cleanExpiredBatchSize+1)
|
||||||
|
|
||||||
|
if deleted != cleanExpiredBatchSize {
|
||||||
|
t.Errorf("one statement deleted %d rows, want %d",
|
||||||
|
deleted, cleanExpiredBatchSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.CleanExpired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanExpired failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count := negativeCacheTableRowCount(t, cache); count != 1 {
|
||||||
|
t.Errorf("negative_cache holds %d rows after CleanExpired, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if negativeCacheRowCount(t, cache, notExpiredPath) != 1 {
|
||||||
|
t.Error("the row that has not expired was removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanExpiredRemovesMoreThanOneBatch stores one expired negative
|
||||||
|
// cache row more than one batch holds and calls CleanExpired once: it
|
||||||
|
// must remove them all, so it fails if CleanExpired stops after its
|
||||||
|
// first batch.
|
||||||
|
func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1)
|
||||||
|
|
||||||
|
before := negativeCacheTableRowCount(t, cache)
|
||||||
|
|
||||||
|
err = cache.CleanExpired(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanExpired failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := negativeCacheTableRowCount(t, cache)
|
||||||
|
|
||||||
|
t.Logf("CleanExpired removed %d of %d expired rows, %d per batch",
|
||||||
|
before-after, before, cleanExpiredBatchSize)
|
||||||
|
|
||||||
|
if after != 0 {
|
||||||
|
t.Errorf("negative_cache holds %d rows after CleanExpired, want 0", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanExpiredStopsOnceContextEnds runs CleanExpired with a context
|
||||||
|
// that has already ended: it must return the context's error and delete
|
||||||
|
// no row.
|
||||||
|
func TestCleanExpiredStopsOnceContextEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertExpiredNegativeCacheRows(t, cache, 1)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err = cache.CleanExpired(ctx)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("CleanExpired() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count := negativeCacheTableRowCount(t, cache); count != 1 {
|
||||||
|
t.Errorf("negative_cache holds %d rows, want the expired row kept", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertExpiredNegativeCacheRows inserts count negative_cache rows that
|
||||||
|
// expired an hour ago, each for a different source path.
|
||||||
|
func insertExpiredNegativeCacheRows(t *testing.T, cache *Cache, count int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for i := range count {
|
||||||
|
_, err := cache.db.ExecContext(t.Context(), `
|
||||||
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, status_code, expires_at)
|
||||||
|
VALUES (?, ?, 404, datetime('now', '-1 hour'))
|
||||||
|
`, testHostExample, "/expired/"+strconv.Itoa(i)+".jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert an expired row: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// negativeCacheTableRowCount returns how many rows negative_cache holds.
|
||||||
|
func negativeCacheTableRowCount(tb testing.TB, cache *Cache) int {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := cache.db.QueryRowContext(tb.Context(),
|
||||||
|
`SELECT COUNT(*) FROM negative_cache`,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("failed to count negative_cache rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getImage calls Get for req and returns the image it answered with and
|
||||||
|
// its cache status.
|
||||||
|
func getImage(t *testing.T, svc *Service, req *ImageRequest) ([]byte, CacheStatus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of quality %d error = %v", req.Quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the image of quality %d: %v", req.Quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, resp.CacheStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSize returns the size of the file at path.
|
||||||
|
func fileSize(t *testing.T, path string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateFile cuts the file at path down to size bytes.
|
||||||
|
func truncateFile(t *testing.T, path string, size int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Logf("cutting %s from %d to %d bytes",
|
||||||
|
filepath.Base(path), fileSize(t, path), size)
|
||||||
|
|
||||||
|
err := os.Truncate(path, size)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("truncating %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedSourcePath returns the path of the cached source file req uses.
|
||||||
|
func cachedSourcePath(t *testing.T, svc *Service, req *ImageRequest) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
|
||||||
|
if err != nil || contentHash == "" {
|
||||||
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc.cache.srcContent.hashToPath(contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
||||||
|
// when it starts, and returns what the pass logged.
|
||||||
|
func reconcileAsAtStartup(t *testing.T, cache *Cache) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||||
|
|
||||||
|
err := cache.reconcileAccounting(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcileAccounting() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("reconciliation log: %s", logBuf.String())
|
||||||
|
|
||||||
|
return logBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertWarningNames checks that logs hold a warning naming the file at
|
||||||
|
// path.
|
||||||
|
func assertWarningNames(t *testing.T, logs, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for line := range strings.Lines(logs) {
|
||||||
|
if strings.Contains(line, `"level":"WARN"`) &&
|
||||||
|
strings.Contains(line, `"path":"`+path+`"`) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("reconciliation logged no warning naming %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_FetchesAgainForEmptyCachedSource empties the cached source
|
||||||
|
// file, as a crash or a restored snapshot could, and runs the reconciliation
|
||||||
|
// pass, which must remove it with a warning naming it. It then asks for two
|
||||||
|
// new sizes of the source: the first must fetch it again and cache it whole,
|
||||||
|
// so that the second is made from the cache without a third fetch.
|
||||||
|
func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
truncateFile(t, path, 0)
|
||||||
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
||||||
|
|
||||||
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2: the empty cached source "+
|
||||||
|
"was kept and fetched again on every new size", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_FetchesAgainForPartialCachedSource cuts the cached source
|
||||||
|
// file to half its size, as a crash or a restored snapshot could, and runs
|
||||||
|
// the reconciliation pass, which must remove it with a warning naming it.
|
||||||
|
// It then asks for two new sizes of the source: the first must fetch it
|
||||||
|
// again rather than fail to decode the partial file, and the second is
|
||||||
|
// made from the cache.
|
||||||
|
func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
truncateFile(t, path, wholeSize/2)
|
||||||
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
||||||
|
|
||||||
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertConvertedAgain caches a variant of the photo, cuts its file to size
|
||||||
|
// bytes and runs the reconciliation pass, which must remove it with a
|
||||||
|
// warning naming it. It then checks that Get converts the image again
|
||||||
|
// instead of serving the file, and that the next Get serves the whole
|
||||||
|
// variant from the cache.
|
||||||
|
func assertConvertedAgain(t *testing.T, size int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := svc.cache.variants.keyToPath(CacheKey(req))
|
||||||
|
|
||||||
|
truncateFile(t, path, size)
|
||||||
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the cut: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the cut: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"converted again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
t.Logf("next request: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConvertsAgainForEmptyCachedVariant empties a cached
|
||||||
|
// variant file, as a crash or a restored snapshot could, and checks that it
|
||||||
|
// is converted again rather than served empty.
|
||||||
|
func TestService_Get_ConvertsAgainForEmptyCachedVariant(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertConvertedAgain(t, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConvertsAgainForPartialCachedVariant cuts a cached
|
||||||
|
// variant file to ten bytes, as a crash or a restored snapshot could, and
|
||||||
|
// checks that it is converted again rather than served cut short.
|
||||||
|
func TestService_Get_ConvertsAgainForPartialCachedVariant(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertConvertedAgain(t, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconciliationRemovesEmptyVariantFileWithoutRow checks that the
|
||||||
|
// reconciliation pass removes an empty variant file that has no
|
||||||
|
// accounting row, with a warning naming it, instead of adding a row for
|
||||||
|
// it that would have it served empty.
|
||||||
|
func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil),
|
||||||
|
testContentTypeWebP)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to store an empty variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := reconcileAsAtStartup(t, cache)
|
||||||
|
|
||||||
|
assertWarningNames(t, logs, cache.variants.keyToPath(testVariantKeyOne))
|
||||||
|
|
||||||
|
if cache.variants.Exists(testVariantKeyOne) {
|
||||||
|
t.Error("the empty variant file is still on disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`,
|
||||||
|
string(testVariantKeyOne))
|
||||||
|
if rows != 0 {
|
||||||
|
t.Errorf("the empty variant file got %d accounting rows, want 0", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVariantStorage_StoreReplacesMetaFile stores a variant over one
|
||||||
|
// already stored and checks that Store puts a new .meta file in place of
|
||||||
|
// the old one, as it does the image, rather than writing into the old
|
||||||
|
// file, which a crash could leave empty. The old .meta file is given a
|
||||||
|
// second name before the store, so that it can be told apart from the new
|
||||||
|
// one after it.
|
||||||
|
func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storage, key := storeTestVariant(t)
|
||||||
|
metaPath := storage.keyToPath(key) + variantMetaSuffix
|
||||||
|
oldMetaPath := metaPath + ".old"
|
||||||
|
|
||||||
|
err := os.Link(metaPath, oldMetaPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("giving the .meta file a second name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = storage.Store(key, bytes.NewReader([]byte("new variant data")),
|
||||||
|
testContentTypeJPEG)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldMeta, err := os.Stat(oldMetaPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat of the old .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMeta, err := os.Stat(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat of the new .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.SameFile(oldMeta, newMeta) {
|
||||||
|
t.Error("Store() wrote into the old .meta file instead of putting " +
|
||||||
|
"a new one in its place")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := storage.LoadWithMeta(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadWithMeta() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
t.Logf("content type after the store: %s", contentType)
|
||||||
|
|
||||||
|
if contentType != testContentTypeJPEG {
|
||||||
|
t.Errorf("content type = %q, want %q", contentType, testContentTypeJPEG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a
|
||||||
|
// store over a file of another size at the content's path, such as a
|
||||||
|
// partial file a crash left, replaces it with the content.
|
||||||
|
func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storage, err := NewContentStorage(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := []byte("the whole source image")
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
hash := ContentHash(hex.EncodeToString(sum[:]))
|
||||||
|
path := storage.hashToPath(hash)
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating the content's directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing a partial file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = storage.StoreHashed(hash, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreHashed() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := storage.Load(hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = stored.Close() }()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(stored)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the stored content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, content) {
|
||||||
|
t.Errorf("stored file = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartPendingCountWrites starts the goroutine that writes the pending
|
||||||
|
// counts to the database whenever there are some, one UPDATE at a time:
|
||||||
|
// however many requests pass their deadline before their counts are
|
||||||
|
// written, at most this one write waits for the database. It is a no-op
|
||||||
|
// when already started. The goroutine outlives the caller, so it runs with
|
||||||
|
// its own context, which StopPendingCountWrites cancels to tell it to
|
||||||
|
// finish.
|
||||||
|
func (c *Cache) StartPendingCountWrites() {
|
||||||
|
if c.pendingCountsCancel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c.pendingCountsCancel = cancel
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
c.pendingCountWriteLoop(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
|
||||||
|
// started, if it did, to finish, and waits for its write under way to end
|
||||||
|
// and for it to return. It then writes the pending counts left, so that
|
||||||
|
// they are written before the database closes. It waits for all of this at
|
||||||
|
// most until ctx ends, then logs the counts not written and returns an
|
||||||
|
// error.
|
||||||
|
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if c.pendingCountsCancel != nil {
|
||||||
|
c.pendingCountsCancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.pendingCountsDone:
|
||||||
|
case <-ctx.Done():
|
||||||
|
// The write given up on here may not reach the database:
|
||||||
|
// database/sql closes without waiting for a query, and a
|
||||||
|
// write still waiting for the connection fails when the
|
||||||
|
// database closes.
|
||||||
|
err = fmt.Errorf("pending counts still being written: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
err = c.writePendingCounts(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.log.Error("counts not written at shutdown",
|
||||||
|
"hits", c.pendingHits.Load(),
|
||||||
|
"misses", c.pendingMisses.Load(),
|
||||||
|
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
||||||
|
"upstream_fetch_bytes", c.pendingUpstreamFetchBytes.Load(),
|
||||||
|
"transforms", c.pendingTransforms.Load(),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingCountWriteLoop is the body of the goroutine
|
||||||
|
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
|
||||||
|
// and returns, but does not cut off a write under way then. A write that
|
||||||
|
// fails leaves the counts pending, for the next write.
|
||||||
|
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
||||||
|
defer close(c.pendingCountsDone)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-c.pendingCountsAdded:
|
||||||
|
}
|
||||||
|
|
||||||
|
// select picks at random when ctx is cancelled and a wakeup
|
||||||
|
// waits too.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.writePendingCounts(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to write pending counts", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPendingCount adds n to pendingCount, one of the pending counts, and
|
||||||
|
// wakes the goroutine that writes them. pendingCountsAdded has capacity one,
|
||||||
|
// so a wakeup already waiting is enough.
|
||||||
|
func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
|
||||||
|
pendingCount.Add(n)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.pendingCountsAdded <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePendingCounts adds the pending counts to the cache_stats row in one
|
||||||
|
// UPDATE, then takes what it wrote out of them, leaving any added
|
||||||
|
// meanwhile. When the UPDATE fails, they all stay pending.
|
||||||
|
func (c *Cache) writePendingCounts(ctx context.Context) error {
|
||||||
|
c.pendingCountsWriteMutex.Lock()
|
||||||
|
defer c.pendingCountsWriteMutex.Unlock()
|
||||||
|
|
||||||
|
hits := c.pendingHits.Load()
|
||||||
|
misses := c.pendingMisses.Load()
|
||||||
|
upstreamFetches := c.pendingUpstreamFetches.Load()
|
||||||
|
upstreamFetchBytes := c.pendingUpstreamFetchBytes.Load()
|
||||||
|
transforms := c.pendingTransforms.Load()
|
||||||
|
|
||||||
|
if hits+misses+upstreamFetches+upstreamFetchBytes+transforms == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE cache_stats
|
||||||
|
SET hit_count = hit_count + ?,
|
||||||
|
miss_count = miss_count + ?,
|
||||||
|
upstream_fetch_count = upstream_fetch_count + ?,
|
||||||
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||||
|
transform_count = transform_count + ?,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
`, hits, misses, upstreamFetches, upstreamFetchBytes, transforms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write pending counts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.pendingHits.Add(-hits)
|
||||||
|
c.pendingMisses.Add(-misses)
|
||||||
|
c.pendingUpstreamFetches.Add(-upstreamFetches)
|
||||||
|
c.pendingUpstreamFetchBytes.Add(-upstreamFetchBytes)
|
||||||
|
c.pendingTransforms.Add(-transforms)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingCountsWait is how long a test waits for the database's connection,
|
||||||
|
// for a count to reach the database, for a write to start waiting for the
|
||||||
|
// connection, for a request to return past its deadline, or for
|
||||||
|
// StopPendingCountWrites to return.
|
||||||
|
const pendingCountsWait = 5 * time.Second
|
||||||
|
|
||||||
|
// otherWritesWait is how long a test gives other writes, once one write waits
|
||||||
|
// for the database's connection, to start waiting for it too.
|
||||||
|
const otherWritesWait = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// holdDatabase takes the one connection of cache's database, so that every
|
||||||
|
// other query waits for it, and returns the func that frees it.
|
||||||
|
func holdDatabase(t *testing.T, cache *Cache) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), pendingCountsWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := cache.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() { _ = conn.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForConnectionWaits waits until db.Stats().WaitCount, the number of
|
||||||
|
// times a caller has waited for a connection, reaches waits.
|
||||||
|
func waitForConnectionWaits(t *testing.T, db *sql.DB, waits int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingCountsWait)
|
||||||
|
|
||||||
|
for db.Stats().WaitCount < waits {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("callers waited for the database connection %d times, want %d",
|
||||||
|
db.Stats().WaitCount, waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForCounters waits until the cache_stats row holds want.
|
||||||
|
func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingCountsWait)
|
||||||
|
|
||||||
|
for {
|
||||||
|
got := readCacheStatsCounters(t, cache)
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("counters = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
||||||
|
// database's one connection while a request whose fetch is held reaches its
|
||||||
|
// deadline. The request must return with the deadline's error while the
|
||||||
|
// connection is still held, as it waits for no database write past its
|
||||||
|
// deadline, and its miss must reach the database once the connection is free.
|
||||||
|
func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
svc.cache.StartPendingCountWrites()
|
||||||
|
defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
|
||||||
|
|
||||||
|
// Room for the request to reach its fetch on a busy host
|
||||||
|
const timeout = 2 * time.Second
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
|
||||||
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
// The request has made its database reads by the time it fetches.
|
||||||
|
select {
|
||||||
|
case <-fetcher.started:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
t.Fatal("request did not reach its fetch by its deadline")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
t.Logf("Get() returned %v after its deadline, error = %v",
|
||||||
|
time.Since(deadline), got.err)
|
||||||
|
|
||||||
|
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Until(deadline) + pendingCountsWait):
|
||||||
|
t.Fatal("request did not return while the database was busy")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting holds the
|
||||||
|
// database's one connection while many misses are counted past their
|
||||||
|
// deadline. At most one write may then wait for the connection, and every
|
||||||
|
// miss must reach the database once it is free.
|
||||||
|
func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
|
// Writes given a context past its deadline fail at once.
|
||||||
|
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
const misses = 20
|
||||||
|
|
||||||
|
for range misses {
|
||||||
|
cache.IncrementStats(ended, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once one write waits for the connection, any others start waiting too.
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
|
time.Sleep(otherWritesWait)
|
||||||
|
|
||||||
|
waits := cache.db.Stats().WaitCount - waitsBefore
|
||||||
|
t.Logf("%d writes waited for the database", waits)
|
||||||
|
|
||||||
|
if waits > 1 {
|
||||||
|
t.Errorf("%d writes waited for the database, want at most 1", waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
waitForCounters(t, cache, cacheStatsCounters{missCount: misses})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss
|
||||||
|
// their deadline, and checks that Stats adds them to the database's counts
|
||||||
|
// before they are written.
|
||||||
|
func TestStats_IncludesPendingCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
_, err := cache.db.ExecContext(t.Context(),
|
||||||
|
`UPDATE cache_stats SET hit_count = 75, miss_count = 25 WHERE id = 1`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes given a context past its deadline fail at once.
|
||||||
|
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cache.IncrementStats(ended, true, 0)
|
||||||
|
cache.IncrementStats(ended, true, 0)
|
||||||
|
cache.IncrementStats(ended, false, 0)
|
||||||
|
|
||||||
|
stats, err := cache.Stats(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.HitCount != 77 || stats.MissCount != 26 {
|
||||||
|
t.Errorf("HitCount = %d, MissCount = %d, want 77 and 26",
|
||||||
|
stats.HitCount, stats.MissCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := cacheStatsCounters{hitCount: 75, missCount: 25}
|
||||||
|
if got := readCacheStatsCounters(t, cache); got != want {
|
||||||
|
t.Errorf("counters in the database = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingCountWrites_WritesPendingCounts holds the database's one
|
||||||
|
// connection while the goroutine that writes the pending counts waits for it,
|
||||||
|
// counts more, then stops that goroutine. The stop must leave the write under
|
||||||
|
// way to finish rather than cut it off, and once the connection is free it
|
||||||
|
// must write the counts that write left.
|
||||||
|
func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
|
// Writes given a context past its deadline fail at once.
|
||||||
|
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cache.IncrementStats(ended, true, 0)
|
||||||
|
|
||||||
|
// The goroutine's write waits for the connection.
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
|
|
||||||
|
// Counted after that write read the pending counts
|
||||||
|
cache.IncrementStats(ended, false, 0)
|
||||||
|
cache.IncrementUpstreamFetch(ended, 1024)
|
||||||
|
cache.IncrementTransformCount(ended)
|
||||||
|
|
||||||
|
// The goroutine is told to finish here as well as by the stop, so that it
|
||||||
|
// is told before its write under way ends, however late the stop runs.
|
||||||
|
// The counts above then reach the database only through the stop's own
|
||||||
|
// write.
|
||||||
|
cache.pendingCountsCancel()
|
||||||
|
|
||||||
|
stopped := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
stopped <- cache.StopPendingCountWrites(t.Context())
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Had the stop cut off the write under way, its own write would wait for
|
||||||
|
// the connection too.
|
||||||
|
time.Sleep(otherWritesWait)
|
||||||
|
|
||||||
|
waits := cache.db.Stats().WaitCount - waitsBefore
|
||||||
|
t.Logf("%d writes waited for the database", waits)
|
||||||
|
|
||||||
|
if waits > 1 {
|
||||||
|
t.Errorf("%d writes waited for the database, want 1: "+
|
||||||
|
"the stop cut off the write under way", waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopped:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StopPendingCountWrites() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("StopPendingCountWrites() did not return once the database was free")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := cacheStatsCounters{1, 1, 1, 1024, 1}
|
||||||
|
if got := readCacheStatsCounters(t, cache); got != want {
|
||||||
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingCountWrites_ReturnsWhenItsContextEnds holds the database's
|
||||||
|
// one connection while the goroutine that writes the pending counts waits for
|
||||||
|
// it, then stops that goroutine with a context that ends first. The stop must
|
||||||
|
// return the context's error, and the write under way must still reach the
|
||||||
|
// database once the connection is free.
|
||||||
|
func TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
|
// Writes given a context past its deadline fail at once.
|
||||||
|
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cache.IncrementStats(ended, true, 0)
|
||||||
|
|
||||||
|
// The goroutine's write waits for the connection.
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
|
|
||||||
|
stopCtx, cancelStop := context.WithCancel(t.Context())
|
||||||
|
cancelStop()
|
||||||
|
|
||||||
|
stopped := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
stopped <- cache.StopPendingCountWrites(stopCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopped:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("StopPendingCountWrites() error = %v, want %v",
|
||||||
|
err, context.Canceled)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("StopPendingCountWrites() did not return once its context ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
@@ -42,7 +43,8 @@ type Service struct {
|
|||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
// Cache is the cache instance
|
// Cache is the cache instance
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
// FetcherConfig configures the upstream fetcher built when Fetcher is
|
||||||
|
// not set. Its AllowHTTP and MaxResponseSize are used either way.
|
||||||
FetcherConfig *httpfetcher.Config
|
FetcherConfig *httpfetcher.Config
|
||||||
// Fetcher is an optional custom fetcher (for testing)
|
// Fetcher is an optional custom fetcher (for testing)
|
||||||
Fetcher httpfetcher.Fetcher
|
Fetcher httpfetcher.Fetcher
|
||||||
@@ -51,7 +53,7 @@ type ServiceConfig struct {
|
|||||||
// Allowlist is the list of hosts that don't require signatures
|
// Allowlist is the list of hosts that don't require signatures
|
||||||
Allowlist []string
|
Allowlist []string
|
||||||
// MaxConcurrentProcessing is the most images processed at once; zero
|
// MaxConcurrentProcessing is the most images processed at once; zero
|
||||||
// uses the image processor's default, one per CPU
|
// uses the image processor's default, half the CPUs
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
@@ -99,6 +101,13 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JPEG XL is the default output format, so pixad does not start
|
||||||
|
// without it.
|
||||||
|
err := imageprocessor.CheckJPEGXLSupport()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
maxResponseSize := fetcherCfg.MaxResponseSize
|
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||||
processor := imageprocessor.New(imageprocessor.Params{
|
processor := imageprocessor.New(imageprocessor.Params{
|
||||||
MaxInputBytes: maxResponseSize,
|
MaxInputBytes: maxResponseSize,
|
||||||
@@ -120,26 +129,20 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
// ErrNegativeCached is returned when a URL is in the negative cache (recently failed).
|
// ErrNegativeCached is returned when a URL is in the negative cache (recently failed).
|
||||||
var ErrNegativeCached = errors.New("request is in negative cache (recently failed)")
|
var ErrNegativeCached = errors.New("request is in negative cache (recently failed)")
|
||||||
|
|
||||||
|
// ErrProcessingTimedOut is returned when processing an image stopped because
|
||||||
|
// the downstream_timeout of the request doing it ended, to that request and to
|
||||||
|
// every request that waited for its image.
|
||||||
|
var ErrProcessingTimedOut = errors.New(
|
||||||
|
"image processing stopped at the deadline of the request doing it")
|
||||||
|
|
||||||
// Get retrieves a processed image, fetching and processing if necessary.
|
// Get retrieves a processed image, fetching and processing if necessary.
|
||||||
|
// While its source URL is in the negative cache, a cached variant is still
|
||||||
|
// served and a variant not in the cache is still made from a cached source;
|
||||||
|
// only a request that needs the source fetched is refused.
|
||||||
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
||||||
// Propagate AllowHTTP setting to the request
|
// Propagate AllowHTTP setting to the request
|
||||||
req.AllowHTTP = s.allowHTTP
|
req.AllowHTTP = s.allowHTTP
|
||||||
|
|
||||||
// Check negative cache first - skip fetching for recently-failed URLs
|
|
||||||
negHit, err := s.cache.checkNegativeCache(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("negative cache check failed", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if negHit {
|
|
||||||
s.log.Debug("negative cache hit",
|
|
||||||
"host", req.SourceHost,
|
|
||||||
"path", req.SourcePath,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check variant cache first (disk only, no DB)
|
// Check variant cache first (disk only, no DB)
|
||||||
result, err := s.cache.Lookup(ctx, req)
|
result, err := s.cache.Lookup(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,7 +157,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
// Fall through to re-process
|
// Fall through to re-process
|
||||||
} else {
|
} else {
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
|
s.cache.IncrementStats(ctx, true, 0)
|
||||||
|
|
||||||
return &ImageResponse{
|
return &ImageResponse{
|
||||||
Content: reader,
|
Content: reader,
|
||||||
@@ -171,7 +174,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
// failed or the request context has ended meanwhile
|
// failed or the request context has ended meanwhile
|
||||||
response, err := s.processOrWait(ctx, req)
|
response, err := s.processOrWait(ctx, req)
|
||||||
|
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), false, 0)
|
s.cache.IncrementStats(ctx, false, 0)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -210,7 +213,16 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
|
|||||||
return fmt.Errorf("invalid source URL: %w", err)
|
return fmt.Errorf("invalid source URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.allowlist.IsAllowed(parsedURL) {
|
// An allowlist entry names no port, so a host followed by ":", with or
|
||||||
|
// without a port, needs a signature. The port is read from the parsed
|
||||||
|
// URL, as the fetcher reads it: in an IPv6 address written without
|
||||||
|
// brackets, such as 2001:db8::1:8443, it is what follows the last ":".
|
||||||
|
// This is checked here and not in allowlist.IsAllowed, which also matches
|
||||||
|
// referer_blocklist, where a Referer must be blocked whatever its port.
|
||||||
|
hostHasPort := parsedURL.Port() != "" ||
|
||||||
|
strings.HasSuffix(parsedURL.Host, ":")
|
||||||
|
|
||||||
|
if !hostHasPort && s.allowlist.IsAllowed(parsedURL) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,14 +304,8 @@ func (s *Service) processOrWait(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
processingCtx := context.WithoutCancel(ctx)
|
processingCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
|
|
||||||
processingCtx, cancel = context.WithDeadline(processingCtx, deadline)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
return s.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
||||||
})
|
})
|
||||||
@@ -336,6 +342,20 @@ func (s *Service) processOrWait(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withoutCancelKeepingDeadline returns ctx without its cancellation but with
|
||||||
|
// its deadline, if it has one, and the func that releases the returned
|
||||||
|
// context.
|
||||||
|
func withoutCancelKeepingDeadline(
|
||||||
|
ctx context.Context,
|
||||||
|
) (context.Context, context.CancelFunc) {
|
||||||
|
deadline, hasDeadline := ctx.Deadline()
|
||||||
|
if !hasDeadline {
|
||||||
|
return context.WithoutCancel(ctx), func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
|
||||||
|
}
|
||||||
|
|
||||||
// loadCachedSource opens source content from cache, without reading it, and
|
// loadCachedSource opens source content from cache, without reading it, and
|
||||||
// returns it with its size; nil if the cached data is unavailable, empty or
|
// returns it with its size; nil if the cached data is unavailable, empty or
|
||||||
// exceeds maxResponseSize.
|
// exceeds maxResponseSize.
|
||||||
@@ -407,12 +427,28 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
|
|
||||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||||
// It counts the fetch with the bytes read from upstream, including when
|
// It counts the fetch with the bytes read from upstream, including when
|
||||||
// reading the response or a later step fails.
|
// reading the response or a later step fails. It fetches nothing while the
|
||||||
|
// source URL is in the negative cache.
|
||||||
func (s *Service) fetchAndProcess(
|
func (s *Service) fetchAndProcess(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
cacheKey VariantKey,
|
cacheKey VariantKey,
|
||||||
) (*processedVariant, error) {
|
) (*processedVariant, error) {
|
||||||
|
// Check negative cache - skip fetching for recently-failed URLs
|
||||||
|
negHit, err := s.cache.checkNegativeCache(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("negative cache check failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if negHit {
|
||||||
|
s.log.Debug("negative cache hit",
|
||||||
|
"host", req.SourceHost,
|
||||||
|
"path", req.SourcePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch from upstream
|
// Fetch from upstream
|
||||||
sourceURL := req.SourceURL()
|
sourceURL := req.SourceURL()
|
||||||
|
|
||||||
@@ -445,7 +481,7 @@ func (s *Service) fetchAndProcess(
|
|||||||
fetchBytes := int64(len(sourceData))
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementUpstreamFetch(context.WithoutCancel(ctx), fetchBytes)
|
s.cache.IncrementUpstreamFetch(ctx, fetchBytes)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
@@ -519,7 +555,13 @@ func (s *Service) processAndStore(
|
|||||||
FitMode: imageprocessor.FitMode(req.FitMode),
|
FitMode: imageprocessor.FitMode(req.FitMode),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ctx has the deadline of the request doing the processing, which can
|
||||||
|
// end while a request waiting for its image has time left
|
||||||
processResult, err := s.processor.Process(ctx, source, processReq)
|
processResult, err := s.processor.Process(ctx, source, processReq)
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -527,7 +569,7 @@ func (s *Service) processAndStore(
|
|||||||
processDuration := time.Since(processStart)
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
|
s.cache.IncrementTransformCount(ctx)
|
||||||
|
|
||||||
// Read processed content
|
// Read processed content
|
||||||
processedData, err := io.ReadAll(processResult.Content)
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -248,6 +250,97 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_ValidateRequest_HostWithPortNeedsSignature verifies that a host
|
||||||
|
// followed by a port, or by a colon with no port, matches no allowlist entry,
|
||||||
|
// so that a request for it without a signature is refused, while the same
|
||||||
|
// host with no port is allowlisted.
|
||||||
|
func TestService_ValidateRequest_HostWithPortNeedsSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, _ := SetupTestService(t,
|
||||||
|
WithSigningKey(testSigningKey),
|
||||||
|
WithAllowlist(testHostCDN, "203.0.113.5", "2001:db8::1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
allowlisted bool
|
||||||
|
}{
|
||||||
|
{testHostCDN, true},
|
||||||
|
{"cdn.example.com:8443", false},
|
||||||
|
{"cdn.example.com:443", false},
|
||||||
|
{"cdn.example.com:", false},
|
||||||
|
{"203.0.113.5", true},
|
||||||
|
{"203.0.113.5:8080", false},
|
||||||
|
{"[2001:db8::1]", true},
|
||||||
|
{"[2001:db8::1]:8443", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: tt.host,
|
||||||
|
SourcePath: testPathCat,
|
||||||
|
Size: Size{Width: 50, Height: 50},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.ValidateRequest(req)
|
||||||
|
t.Logf("ValidateRequest() for host %q with no signature: %v",
|
||||||
|
tt.host, err)
|
||||||
|
|
||||||
|
if allowlisted := err == nil; allowlisted != tt.allowlisted {
|
||||||
|
t.Errorf("host %q served without a signature = %v, want %v",
|
||||||
|
tt.host, allowlisted, tt.allowlisted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature verifies that
|
||||||
|
// an allowlisted IPv6 address written without brackets and followed by a
|
||||||
|
// port, or by a colon with no port, needs a signature. Go's URL parser, which
|
||||||
|
// the fetcher uses, reads 2001:db8::1:8443 as the host 2001:db8::1 with the
|
||||||
|
// port 8443.
|
||||||
|
func TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, _ := SetupTestService(t,
|
||||||
|
WithSigningKey(testSigningKey),
|
||||||
|
WithAllowlist("2001:db8::1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, host := range []string{"2001:db8::1:8443", "2001:db8::1:"} {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: host,
|
||||||
|
SourcePath: testPathCat,
|
||||||
|
Size: Size{Width: 50, Height: 50},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.ValidateRequest(req)
|
||||||
|
t.Logf("ValidateRequest() for host %q with no signature: %v",
|
||||||
|
host, err)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("host %q served without a signature, want refused",
|
||||||
|
host)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_Get_InvalidFile(t *testing.T) {
|
func TestService_Get_InvalidFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -430,6 +523,172 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
||||||
|
// a variant of it and sets back two days every time pixa stored for them.
|
||||||
|
// For the source: fetched_at in source_metadata, fetched_at and
|
||||||
|
// last_accessed_at in source_content, the fetched_at in its metadata file,
|
||||||
|
// and the access and modification times of its file and of its metadata
|
||||||
|
// file. For the variant: created_at and last_accessed_at in variant_content,
|
||||||
|
// the created_at in its .meta file, and the access and modification times of
|
||||||
|
// its file and of its .meta file. It then checks that the variant is still
|
||||||
|
// served from the cache and that a new variant is made from the cached source
|
||||||
|
// without fetching it again.
|
||||||
|
//
|
||||||
|
// Left at the present: the files' status change and birth times, which a
|
||||||
|
// program cannot set, and the times of the directories holding the files,
|
||||||
|
// which other entries share. The source's stored response headers are empty,
|
||||||
|
// as the mock fetcher sends none. expires_at, etag and last_modified in
|
||||||
|
// source_metadata and in the metadata file are never written, nor are the
|
||||||
|
// request_cache and output_content tables.
|
||||||
|
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
variant := photoVariant(fixtures, 85, FitCover)
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), variant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
twoDaysAgo := time.Now().AddDate(0, 0, -2)
|
||||||
|
backdateSource(t, svc.cache, variant, twoDaysAgo)
|
||||||
|
backdateVariant(t, svc.cache, variant, twoDaysAgo)
|
||||||
|
|
||||||
|
resp, err = svc.Get(t.Context(), variant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
if resp.CacheStatus != CacheHit {
|
||||||
|
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||||
|
resp.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another quality is a new variant, made from the cached source
|
||||||
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of a new variant error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
|
if fetches != 1 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backdateSource sets every time pixa stored for req's cached source to when,
|
||||||
|
// as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||||
|
func backdateSource(
|
||||||
|
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||||
|
|
||||||
|
meta, err := cache.srcMetadata.Load(req.SourceHost, pathHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the source's metadata file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.FetchedAt = when.Unix()
|
||||||
|
|
||||||
|
err = cache.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing the source's metadata file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
||||||
|
|
||||||
|
updateOneRow(t, cache, `
|
||||||
|
UPDATE source_metadata SET fetched_at = ?
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, dbTime, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||||
|
updateOneRow(t, cache,
|
||||||
|
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
|
||||||
|
dbTime, meta.ContentHash)
|
||||||
|
setSourceLastAccessed(t, cache, ContentHash(meta.ContentHash), when)
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
|
||||||
|
cache.srcMetadata.metaPath(req.SourceHost, pathHash),
|
||||||
|
} {
|
||||||
|
err = os.Chtimes(path, when, when)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backdateVariant sets every time pixa stored for the cached variant req asks
|
||||||
|
// for to when, as listed on
|
||||||
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||||
|
func backdateVariant(
|
||||||
|
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key := CacheKey(req)
|
||||||
|
|
||||||
|
content, size, contentType, err := cache.variants.LoadWithMeta(key)
|
||||||
|
if err != nil || contentType == "" {
|
||||||
|
t.Fatalf("reading the variant's .meta file: content type %q, error %v",
|
||||||
|
contentType, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = content.Close()
|
||||||
|
|
||||||
|
meta, err := json.Marshal(VariantMeta{
|
||||||
|
ContentType: contentType,
|
||||||
|
Size: size,
|
||||||
|
CreatedAt: when.Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoding the variant's .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := cache.variants.keyToPath(key)
|
||||||
|
|
||||||
|
err = os.WriteFile(path+variantMetaSuffix, meta, StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing the variant's .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOneRow(t, cache,
|
||||||
|
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(key))
|
||||||
|
setVariantLastAccessed(t, cache, key, when)
|
||||||
|
|
||||||
|
for _, file := range []string{path, path + variantMetaSuffix} {
|
||||||
|
err = os.Chtimes(file, when, when)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the times of %s: %v", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOneRow runs the UPDATE statement query and fails the test unless it
|
||||||
|
// changed exactly one row.
|
||||||
|
func updateOneRow(t *testing.T, cache *Cache, query string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := cache.db.ExecContext(t.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", query, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil || rows != 1 {
|
||||||
|
t.Fatalf("%s: rows = %d, error = %v, want 1 row", query, rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_Get_DifferentSizes(t *testing.T) {
|
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,7 +90,6 @@ func TestStats_ZeroCounts(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,7 +118,6 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
})
|
})
|
||||||
@@ -162,7 +159,6 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
})
|
})
|
||||||
@@ -232,7 +228,6 @@ func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
|
|||||||
|
|
||||||
disabled, err := NewCache(enabled.db, CacheConfig{
|
disabled, err := NewCache(enabled.db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
DisableDiskCache: true,
|
DisableDiskCache: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,8 +80,8 @@ func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
|||||||
// from hash, without recomputing it. Callers that already know the
|
// from hash, without recomputing it. Callers that already know the
|
||||||
// hash before writing (e.g. because they must hold a hash-keyed lock
|
// hash before writing (e.g. because they must hold a hash-keyed lock
|
||||||
// across the whole store operation) use this instead of Store. Like
|
// across the whole store operation) use this instead of Store. Like
|
||||||
// Store, it is idempotent: content already on disk at that path is
|
// Store, it is idempotent: a file of the content's size already at that
|
||||||
// left untouched.
|
// path is left untouched, and a file of another size is replaced.
|
||||||
func (s *ContentStorage) StoreHashed(
|
func (s *ContentStorage) StoreHashed(
|
||||||
hash ContentHash, data []byte,
|
hash ContentHash, data []byte,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
@@ -151,28 +152,35 @@ func (s *ContentStorage) Exists(hash ContentHash) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeIfAbsent writes data to the path derived from hash, unless
|
// writeIfAbsent writes data with writeFileSynced to the path derived
|
||||||
// content already exists there, via a temp-file-plus-rename so
|
// from hash, unless a file of the data's size is already there.
|
||||||
// concurrent readers never observe a partial file.
|
|
||||||
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
||||||
// Build path: <basedir>/<ab>/<cd>/<hash>
|
// Build path: <basedir>/<ab>/<cd>/<hash>
|
||||||
path := s.hashToPath(hash)
|
path := s.hashToPath(hash)
|
||||||
|
|
||||||
// Check if already exists
|
// A file of another size there is damaged, and is replaced
|
||||||
_, statErr := os.Stat(path)
|
info, statErr := os.Stat(path)
|
||||||
if statErr == nil {
|
if statErr == nil && info.Size() == int64(len(data)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory structure
|
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err := os.MkdirAll(dir, StorageDirPerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to temp file first, then rename for atomicity
|
return writeFileSynced(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileSynced writes data to path through a temp file in the same
|
||||||
|
// directory: the temp file is synced to disk, renamed to path, and the
|
||||||
|
// directory synced after, so that neither a concurrent reader nor a
|
||||||
|
// crash finds a partial file under path. When the directory sync fails,
|
||||||
|
// the file is removed from path, so that a failed write leaves no file
|
||||||
|
// there.
|
||||||
|
func writeFileSynced(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
@@ -180,18 +188,20 @@ func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
// Each failure path below unlinks the temp file explicitly. This
|
|
||||||
// replaces a deferred cleanup that read a named result, which the
|
|
||||||
// canonical config does not permit; the set of paths that remove
|
|
||||||
// tmpPath, and the order relative to Close, is unchanged. This
|
|
||||||
// mirrors how MetadataStorage.Store and VariantStorage.Store below
|
|
||||||
// already express the same cleanup.
|
|
||||||
_, err = tmpFile.Write(data)
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write content: %w", err)
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpFile.Sync()
|
||||||
|
if err != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tmpFile.Close()
|
err = tmpFile.Close()
|
||||||
@@ -201,7 +211,6 @@ func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
|
||||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
@@ -209,6 +218,31 @@ func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = syncDirectory(dir)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDirectory syncs the directory dir to disk, so that a file just
|
||||||
|
// renamed into it is still there after a crash.
|
||||||
|
func syncDirectory(dir string) error {
|
||||||
|
d, err := os.Open(filepath.Clean(dir))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = d.Sync()
|
||||||
|
_ = d.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,11 +405,13 @@ func HashPath(path string) PathHash {
|
|||||||
|
|
||||||
// CacheKey generates a unique key for a request variant.
|
// CacheKey generates a unique key for a request variant.
|
||||||
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
|
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
|
||||||
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
||||||
|
// either cannot be taken for the one between them.
|
||||||
func CacheKey(req *ImageRequest) VariantKey {
|
func CacheKey(req *ImageRequest) VariantKey {
|
||||||
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
|
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
|
||||||
req.SourceHost,
|
req.SourceHost,
|
||||||
req.SourcePath,
|
escapePercentAndColon(req.SourcePath),
|
||||||
req.SourceQuery,
|
escapePercentAndColon(req.SourceQuery),
|
||||||
req.Size.Width,
|
req.Size.Width,
|
||||||
req.Size.Height,
|
req.Size.Height,
|
||||||
req.Format,
|
req.Format,
|
||||||
@@ -387,6 +423,14 @@ func CacheKey(req *ImageRequest) VariantKey {
|
|||||||
return VariantKey(hex.EncodeToString(h[:]))
|
return VariantKey(hex.EncodeToString(h[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapePercentAndColon returns s with each "%" written as "%25" and each
|
||||||
|
// ":" as "%3A", so that the result holds no ":".
|
||||||
|
func escapePercentAndColon(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "%", "%25")
|
||||||
|
|
||||||
|
return strings.ReplaceAll(s, ":", "%3A")
|
||||||
|
}
|
||||||
|
|
||||||
// VariantStorage handles key-based file storage for processed image variants.
|
// VariantStorage handles key-based file storage for processed image variants.
|
||||||
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
|
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
|
||||||
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
|
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
|
||||||
@@ -416,7 +460,9 @@ func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error
|
|||||||
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content and metadata to storage at the given key.
|
// Store writes content and metadata to storage at the given key, each
|
||||||
|
// with writeFileSynced. The .meta file is written first, so that a crash
|
||||||
|
// cannot leave the content in place without it.
|
||||||
func (s *VariantStorage) Store(
|
func (s *VariantStorage) Store(
|
||||||
key VariantKey, r io.Reader, contentType string,
|
key VariantKey, r io.Reader, contentType string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
@@ -429,46 +475,11 @@ func (s *VariantStorage) Store(
|
|||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
metaPath := path + ".meta"
|
||||||
|
|
||||||
// Create directory structure
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err = os.MkdirAll(dir, StorageDirPerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create directory: %w", err)
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write content to temp file first, then rename for atomicity
|
|
||||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
|
|
||||||
_, err = tmpFile.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
_ = tmpFile.Close()
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to write content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmpFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic rename content
|
|
||||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write metadata
|
|
||||||
meta := VariantMeta{
|
meta := VariantMeta{
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Size: size,
|
Size: size,
|
||||||
@@ -480,13 +491,19 @@ func (s *VariantStorage) Store(
|
|||||||
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata write failure is non-fatal; content is already stored.
|
// Metadata write failure is non-fatal: the content is then served as
|
||||||
err = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
// application/octet-stream.
|
||||||
|
err = writeFileSynced(metaPath, metaData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to write variant metadata sidecar",
|
s.log.Warn("failed to write variant metadata sidecar",
|
||||||
"path", metaPath, "error", err)
|
"path", metaPath, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = writeFileSynced(path, data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,62 @@ func TestCacheKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
|
||||||
|
// URLs whose path or query holds ":" or "%" get different keys.
|
||||||
|
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path1, query1 string
|
||||||
|
path2, query2 string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "colon moved from path to query",
|
||||||
|
path1: "/a:b", query1: "",
|
||||||
|
path2: "/a", query2: "b:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colon in path and its percent-encoding",
|
||||||
|
path1: "/a:b", query1: "",
|
||||||
|
path2: "/a%3Ab", query2: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key1 := CacheKey(&ImageRequest{
|
||||||
|
SourceHost: testHostCDN,
|
||||||
|
SourcePath: tt.path1,
|
||||||
|
SourceQuery: tt.query1,
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
})
|
||||||
|
key2 := CacheKey(&ImageRequest{
|
||||||
|
SourceHost: testHostCDN,
|
||||||
|
SourcePath: tt.path2,
|
||||||
|
SourceQuery: tt.query2,
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
|
||||||
|
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
|
||||||
|
|
||||||
|
if key1 == key2 {
|
||||||
|
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
|
||||||
|
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
|
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
|
||||||
// that fails is logged at warn and the store still succeeds.
|
// that fails is logged at warn and the store still succeeds.
|
||||||
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
|
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ func SetupTestService(
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL parsing errors.
|
// URL parsing errors.
|
||||||
var (
|
var (
|
||||||
ErrInvalidPath = errors.New("invalid image path")
|
ErrInvalidPath = errors.New("invalid image path")
|
||||||
ErrMissingHost = errors.New("missing source host")
|
ErrMissingHost = errors.New("missing source host")
|
||||||
|
ErrInvalidHost = errors.New("invalid source host")
|
||||||
ErrMissingSize = errors.New("missing size specification")
|
ErrMissingSize = errors.New("missing size specification")
|
||||||
ErrInvalidSize = errors.New("invalid size format")
|
ErrInvalidSize = errors.New("invalid size format")
|
||||||
ErrInvalidFormat = errors.New("invalid or unsupported format")
|
ErrInvalidFormat = errors.New("invalid or unsupported format")
|
||||||
@@ -20,8 +23,9 @@ var (
|
|||||||
ErrPathTraversal = errors.New("path traversal detected")
|
ErrPathTraversal = errors.New("path traversal detected")
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxDimension is the maximum allowed width or height.
|
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||||
const MaxDimension = 8192
|
// processor's maximum for an output image.
|
||||||
|
const MaxDimension = imageprocessor.MaxOutputDimension
|
||||||
|
|
||||||
// ValidateDimension checks one requested width or height; name ("width" or
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
// "height") appears in the error. 0 means "original size" and is valid.
|
// "height") appears in the error. 0 means "original size" and is valid.
|
||||||
@@ -38,8 +42,13 @@ func ValidateDimension(name string, value int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
|
||||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
// and a size with no format, such as "800x600" or "orig"
|
||||||
|
var sizeFormatRegex = regexp.MustCompile(
|
||||||
|
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
|
||||||
|
|
||||||
|
// percentEscapeRegex matches one percent-escape, such as "%2e"
|
||||||
|
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
|
||||||
|
|
||||||
// ParsedURL contains the parsed components of an image proxy URL.
|
// ParsedURL contains the parsed components of an image proxy URL.
|
||||||
type ParsedURL struct {
|
type ParsedURL struct {
|
||||||
@@ -55,13 +64,13 @@ type ParsedURL struct {
|
|||||||
Format ImageFormat
|
Format ImageFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImagePath parses the path captured by chi's wildcard:
|
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
|
||||||
// <host>/<path>/<size>.<format>
|
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
|
||||||
// This is the primary entry point when using chi routing.
|
|
||||||
// Examples:
|
// Examples:
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||||
// - cdn.example.com/photos/cat.jpg/orig.png
|
// - cdn.example.com/photos/cat.jpg/orig.png
|
||||||
|
// - cdn.example.com/photos/cat.jpg/800x600
|
||||||
func ParseImagePath(path string) (*ParsedURL, error) {
|
func ParseImagePath(path string) (*ParsedURL, error) {
|
||||||
// Strip leading slash if present (chi may include it)
|
// Strip leading slash if present (chi may include it)
|
||||||
path = strings.TrimPrefix(path, "/")
|
path = strings.TrimPrefix(path, "/")
|
||||||
@@ -72,8 +81,10 @@ func ParseImagePath(path string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(path)
|
return parseImageComponents(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>
|
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
||||||
// Use ParseImagePath instead when working with chi's wildcard capture.
|
// 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) (*ParsedURL, error) {
|
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||||
// Remove the /v1/image/ prefix
|
// Remove the /v1/image/ prefix
|
||||||
const prefix = "/v1/image/"
|
const prefix = "/v1/image/"
|
||||||
@@ -89,15 +100,10 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(remainder)
|
return parseImageComponents(remainder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||||
|
// <host>/<path>/<size> for JPEG XL.
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||||
// Check for path traversal before any other processing
|
// Find the last path segment, which holds "size" or "size.format"
|
||||||
err := checkPathTraversal(remainder)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the last path segment which contains size.format
|
|
||||||
lastSlash := strings.LastIndex(remainder, "/")
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
if lastSlash == -1 {
|
if lastSlash == -1 {
|
||||||
return nil, ErrMissingSize
|
return nil, ErrMissingSize
|
||||||
@@ -135,12 +141,30 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
return nil, ErrMissingHost
|
return nil, ErrMissingHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A URL reads what comes before "@" as a user name and password, and the
|
||||||
|
// rest as the host
|
||||||
|
if strings.Contains(host, "@") {
|
||||||
|
return nil, fmt.Errorf("%w: %q holds @", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host names do not depend on letter case, so the allowlist, the upstream
|
||||||
|
// connection limits, the cache and the signature see one spelling of each
|
||||||
|
// host
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
// Extract query string if present in path
|
// Extract query string if present in path
|
||||||
if qIndex := strings.Index(path, "?"); qIndex != -1 {
|
if qIndex := strings.Index(path, "?"); qIndex != -1 {
|
||||||
query = path[qIndex+1:]
|
query = path[qIndex+1:]
|
||||||
path = path[:qIndex]
|
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 /
|
// Ensure path starts with /
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
path = "/" + path
|
path = "/" + path
|
||||||
@@ -166,12 +190,7 @@ func checkPathTraversal(path string) error {
|
|||||||
// Decode multiple times to catch double-encoding
|
// Decode multiple times to catch double-encoding
|
||||||
decoded := path
|
decoded := path
|
||||||
for range 3 {
|
for range 3 {
|
||||||
newDecoded, err := url.PathUnescape(decoded)
|
newDecoded := decodePercentEscapes(decoded)
|
||||||
if err != nil {
|
|
||||||
// Malformed encoding is suspicious
|
|
||||||
return ErrPathTraversal
|
|
||||||
}
|
|
||||||
|
|
||||||
if newDecoded == decoded {
|
if newDecoded == decoded {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -212,7 +231,19 @@ func checkPathTraversal(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSizeFormat parses strings like "800x600.webp" or "orig.png"
|
// decodePercentEscapes decodes every percent-escape in s, such as %2e, and
|
||||||
|
// keeps a "%" that does not start one as it is, where url.PathUnescape would
|
||||||
|
// refuse the whole string. A source path holding %25, a literal "%", has such
|
||||||
|
// a "%" after one more decoding.
|
||||||
|
func decodePercentEscapes(s string) string {
|
||||||
|
return percentEscapeRegex.ReplaceAllStringFunc(s, func(escape string) string {
|
||||||
|
decoded, _ := url.PathUnescape(escape)
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||||
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
@@ -225,11 +256,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if matches[4] == "orig" {
|
if matches[4] == "orig" {
|
||||||
// "orig.format" pattern
|
// "orig" or "orig.format" pattern
|
||||||
size = Size{Width: 0, Height: 0}
|
size = Size{Width: 0, Height: 0}
|
||||||
formatStr = matches[5]
|
formatStr = matches[5]
|
||||||
} else {
|
} else {
|
||||||
// "WxH.format" pattern
|
// "WxH" or "WxH.format" pattern
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.Atoi(matches[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
@@ -254,6 +285,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A URL that names no format is served, and signed, as JPEG XL
|
||||||
|
if formatStr == "" {
|
||||||
|
return size, FormatJXL, nil
|
||||||
|
}
|
||||||
|
|
||||||
format, err := parseFormat(formatStr)
|
format, err := parseFormat(formatStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
@@ -275,8 +311,12 @@ func parseFormat(s string) (ImageFormat, error) {
|
|||||||
return FormatWebP, nil
|
return FormatWebP, nil
|
||||||
case "avif":
|
case "avif":
|
||||||
return FormatAVIF, nil
|
return FormatAVIF, nil
|
||||||
|
case "jxl":
|
||||||
|
return FormatJXL, nil
|
||||||
case "gif":
|
case "gif":
|
||||||
return FormatGIF, nil
|
return FormatGIF, nil
|
||||||
|
case "auto":
|
||||||
|
return FormatAuto, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseImageURL_AutoFormat verifies that the format auto in an image URL
|
||||||
|
// parses as FormatAuto.
|
||||||
|
func TestParseImageURL_AutoFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImageURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Format != FormatAuto {
|
||||||
|
t.Errorf("Format = %q, want %q", got.Format, FormatAuto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseImageURL_Errors(t *testing.T) {
|
func TestParseImageURL_Errors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -219,6 +234,49 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
||||||
|
// that every spelling of its letter case is the same host to the allowlist,
|
||||||
|
// the upstream connection limits and the cache, and that the path keeps its
|
||||||
|
// case.
|
||||||
|
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImagePath() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("parsed host %q, path %q", got.Host, got.Path)
|
||||||
|
|
||||||
|
if got.Host != testHostCDN {
|
||||||
|
t.Errorf("Host = %q, want %q", got.Host, testHostCDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Path != "/Photos/Cat.jpg" {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, "/Photos/Cat.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
||||||
|
// refused: a URL reads what comes before the "@" as a user name and password
|
||||||
|
// and the rest as the host.
|
||||||
|
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
} {
|
||||||
|
got, err := ParseImagePath(input)
|
||||||
|
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
|
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
||||||
|
input, err, ErrInvalidHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsedURL_ToImageRequest(t *testing.T) {
|
func TestParsedURL_ToImageRequest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -349,6 +407,71 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
||||||
|
// a source query holding %25, the percent-escape of a literal "%", are
|
||||||
|
// accepted as they are, although decoding them once more leaves a "%" that
|
||||||
|
// does not start a percent-escape.
|
||||||
|
func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *ParsedURL
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
||||||
|
want: &ParsedURL{
|
||||||
|
Host: testHostCDN, Path: "/sale/50%25off.jpg",
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
||||||
|
want: &ParsedURL{
|
||||||
|
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImageURL(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertParsedURL(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImageURL_DoubleEncodedTraversalNextToPercent verifies that a
|
||||||
|
// double-encoded ".." is still refused when the path also holds a "%" that
|
||||||
|
// does not start a percent-escape, either as written or after one more
|
||||||
|
// decoding.
|
||||||
|
func TestParseImageURL_DoubleEncodedTraversalNextToPercent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/50%25off.jpg/800x600.jpeg",
|
||||||
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/100%/800x600.jpeg",
|
||||||
|
} {
|
||||||
|
_, err := ParseImageURL(input)
|
||||||
|
t.Logf("ParseImageURL(%q) error = %v", input, err)
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
|
t.Errorf("ParseImageURL(%q) error = %v, want ErrPathTraversal",
|
||||||
|
input, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// errorIs checks if err matches target (handles wrapped errors).
|
// errorIs checks if err matches target (handles wrapped errors).
|
||||||
func errorIs(err, target error) bool {
|
func errorIs(err, target error) bool {
|
||||||
if errors.Is(err, target) {
|
if errors.Is(err, target) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package loadtestorigin is the upstream host script/loadtest points pixad
|
||||||
|
// at, run by cmd/loadtest-origin. It answers every request, whatever its path,
|
||||||
|
// with the same generated JPEG, so each new path is a new source image for
|
||||||
|
// pixad to fetch, and it logs one line per request, so its log counts pixad's
|
||||||
|
// fetches.
|
||||||
|
package loadtestorigin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
listenAddress = ":80"
|
||||||
|
readHeaderTimeout = 10 * time.Second
|
||||||
|
imageWidth = 1600
|
||||||
|
imageHeight = 1200
|
||||||
|
jpegQuality = 85
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run makes the image and serves it on port 80 until the server fails, then
|
||||||
|
// exits the process with status 1.
|
||||||
|
func Run() {
|
||||||
|
photo, err := makeJPEG()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("cannot make the image", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: listenAddress,
|
||||||
|
Handler: newHandler(photo),
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = server.ListenAndServe()
|
||||||
|
slog.Error("server stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHandler answers every request with photo and logs the request's path.
|
||||||
|
func newHandler(photo []byte) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slog.Info("request", "path", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write(photo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeJPEG draws colour gradients crossed with a fine pattern, so the image
|
||||||
|
// has detail to decode and does not compress to almost nothing.
|
||||||
|
func makeJPEG() ([]byte, error) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
|
||||||
|
|
||||||
|
// red and green count up from 0 to 255 and wrap around, along each row
|
||||||
|
// and down the image.
|
||||||
|
var green uint8
|
||||||
|
|
||||||
|
for y := range imageHeight {
|
||||||
|
var red uint8
|
||||||
|
|
||||||
|
for x := range imageWidth {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: red, G: green, B: red ^ green, A: math.MaxUint8,
|
||||||
|
})
|
||||||
|
red++
|
||||||
|
}
|
||||||
|
|
||||||
|
green++
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user