Compare commits
88
Commits
9cde18549e
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c7173c47d8 | ||
|
|
363774c058 | ||
|
|
1616e91a6a | ||
|
|
6830bdc5de | ||
|
|
5b17d1f555 | ||
|
|
3a274aaa44 | ||
|
|
00da62db2c | ||
|
|
b402eaf88c | ||
|
|
697c14633b | ||
|
|
021516e099 | ||
|
|
869b5ba67f | ||
|
|
3a2eb1f4d2 | ||
|
|
fd7d7ed205 | ||
|
|
bce8860c2e | ||
|
|
99735f479b | ||
|
|
baf457d21e | ||
|
|
0c99be939a | ||
|
|
56217cbf4a | ||
|
|
46a55ec15d | ||
|
|
1b920fe000 | ||
|
|
e98b998cb6 | ||
|
|
ed3f8770e6 | ||
|
|
2afe61e301 | ||
|
|
05678eaae5 | ||
|
|
be060a8305 | ||
|
|
e410146fb6 | ||
|
|
6010f5beb0 | ||
|
|
f149813c7e | ||
|
|
45869572ff | ||
|
|
0f3700f7f5 | ||
|
|
582ff66ba6 | ||
|
|
f8d40b89a7 | ||
|
|
50123b2a6d | ||
|
|
2f7365cc9b | ||
|
|
0f5bd51b09 | ||
|
|
db784bf561 | ||
|
|
b7c1226c38 | ||
|
|
10eab440e7 | ||
|
|
3cfcda0730 | ||
|
|
1798cba96c | ||
|
|
37d49ade11 | ||
|
|
b4e5300feb | ||
|
|
4f95cb6a37 | ||
|
|
6f416eac31 | ||
|
|
b95ef1eb69 | ||
|
|
a96eba8083 | ||
|
|
04b5db6fbf | ||
|
|
63fbc98e63 | ||
|
|
61f42e6602 | ||
|
|
5d0b5f864e | ||
|
|
6573b9d1ef | ||
|
|
275e145a6d | ||
|
|
b6e9ac2a93 | ||
|
|
504afea4f8 | ||
|
|
2fb909283d | ||
|
|
6b4a1d7607 | ||
|
|
e34743f070 | ||
|
|
7010d55d72 | ||
|
|
a50364bfca | ||
|
|
e85b5ff033 | ||
|
|
55a609dd77 |
+72
-7
@@ -1,8 +1,73 @@
|
|||||||
.git
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
.gitignore
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
.DS_Store
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
.env*
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
|
# into .gitignore, where `**/` is wrong.
|
||||||
|
#
|
||||||
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
|
#
|
||||||
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
|
# Unlike the standard file, which leaves out all of .git, pixa sends
|
||||||
|
# .git without its config. Without a VERSION build argument the stage
|
||||||
|
# that compiles runs `git describe --tags --always` on .git, which does
|
||||||
|
# not need .git/config; that file can hold a credential, such as a
|
||||||
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
|
.git/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][dD]25519
|
||||||
|
|
||||||
|
# Dependencies: restored inside the image, never copied in.
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# OS metadata.
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Editor state: never a build input, and it churns COPY.
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/*.bak
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.sublime-*
|
||||||
|
|
||||||
|
# pixa's own entries. Nothing in the build reads .gitignore. On the
|
||||||
|
# host, `make build` writes bin/pixad, and the example config keeps its
|
||||||
|
# state directory in data/.
|
||||||
|
.gitignore
|
||||||
|
/bin
|
||||||
|
/data
|
||||||
|
|
||||||
|
# Local config files, kept out of git because they can hold the signing key.
|
||||||
|
**/[cC][oO][nN][fF][iI][gG].[yY][mM][lL]
|
||||||
|
**/[cC][oO][nN][fF][iI][gG].[yY][aA][mM][lL]
|
||||||
|
**/[cC][oO][nN][fF][iI][gG].[dD][eE][vV].[yY][mM][lL]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
||||||
|
# both sides instead of conflicting. Git never reports a conflict here: read
|
||||||
|
# the merged entries after every merge or rebase.
|
||||||
|
TODO.md merge=union
|
||||||
@@ -6,4 +6,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- run: docker build .
|
# The default clone is shallow and has no tags, so the
|
||||||
|
# version the build takes from `git describe` would be a
|
||||||
|
# bare commit; this fetches the whole history with its tags.
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- run: script/cibuild
|
||||||
|
- run: script/docker-smoke
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
|
|
||||||
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Environment / secrets
|
# Environment / secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
@@ -31,5 +37,6 @@ node_modules/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# Local dev configs
|
# Local dev configs
|
||||||
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
config.dev.yml
|
config.dev.yml
|
||||||
|
|||||||
+86
-105
@@ -1,117 +1,98 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.24"
|
timeout: 5m
|
||||||
tests: false
|
modules-download-mode: readonly
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
|
default: all
|
||||||
enable:
|
enable:
|
||||||
# Additional linters requested
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
- testifylint # Checks usage of github.com/stretchr/testify
|
# left to `default: all`, because it carries the module policy below.
|
||||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
- gomodguard_v2
|
||||||
# - tagliatelle # Disabled: we need snake_case for external API compatibility
|
disable:
|
||||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
# Genuinely incompatible with project patterns
|
||||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
- exhaustruct # Requires all struct fields
|
||||||
- nestif # Reports deeply nested if statements
|
- godot # Requires comments to end with periods
|
||||||
- mnd # An analyzer to detect magic numbers
|
- wrapcheck # Too verbose for internal packages
|
||||||
- lll # Reports long lines
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
- gochecknoglobals # Check that no global variables exist
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
# Default/existing linters that are commonly useful
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
- govet
|
settings:
|
||||||
- errcheck
|
|
||||||
- staticcheck
|
|
||||||
- unused
|
|
||||||
- ineffassign
|
|
||||||
- misspell
|
|
||||||
- revive
|
|
||||||
- gosec
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
lll:
|
lll:
|
||||||
line-length: 120
|
line-length: 88
|
||||||
|
funlen:
|
||||||
nestif:
|
lines: 80
|
||||||
min-complexity: 4
|
statements: 50
|
||||||
|
cyclop:
|
||||||
nlreturn:
|
max-complexity: 15
|
||||||
block-size: 2
|
dupl:
|
||||||
|
threshold: 100
|
||||||
revive:
|
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:
|
rules:
|
||||||
- name: var-naming
|
test-support:
|
||||||
arguments:
|
list-mode: lax
|
||||||
- []
|
files:
|
||||||
- []
|
- "$all"
|
||||||
- "upperCaseConst=true"
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
tagliatelle:
|
deny:
|
||||||
case:
|
- pkg: net/http/httptest
|
||||||
rules:
|
desc: >-
|
||||||
json: snake
|
Test-support code belongs in test files and in packages whose
|
||||||
yaml: snake
|
directory name ends in test, not in the shipped binary.
|
||||||
xml: snake
|
# Only decisions already recorded in the Go package defaults are
|
||||||
bson: snake
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
testifylint:
|
blocked:
|
||||||
enable-all: true
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
usetesting: {}
|
- 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
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
exclude-rules:
|
|
||||||
# Exclude unused parameter warnings for cobra command signatures
|
|
||||||
- text: "parameter '(args|cmd)' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow ALL_CAPS constant names
|
|
||||||
- text: "don't use ALL_CAPS in Go names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow snake_case JSON tags for external API compatibility
|
|
||||||
- path: "internal/types/ris.go"
|
|
||||||
linters:
|
|
||||||
- tagliatelle
|
|
||||||
|
|
||||||
# Allow snake_case JSON tags for database models
|
|
||||||
- path: "internal/database/models.go"
|
|
||||||
linters:
|
|
||||||
- tagliatelle
|
|
||||||
|
|
||||||
# Allow generic package name for types that define data structures
|
|
||||||
- path: "internal/types/"
|
|
||||||
text: "avoid meaningless package names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow globals in the globals package (by design)
|
|
||||||
- path: "internal/globals/"
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
# Allow globals in main (Version/Buildarch set by ldflags)
|
|
||||||
- path: "cmd/"
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
# Allow blank imports for driver registration
|
|
||||||
- text: "blank-imports"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow unused fx.Lifecycle parameters (required by fx signature)
|
|
||||||
- text: "parameter 'lc' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow unused context parameters in fx hooks
|
|
||||||
- text: "parameter 'ctx' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# A byte-for-byte copy of the one in sneak/prompts.
|
||||||
|
REPO_POLICIES.md
|
||||||
|
|
||||||
|
vendor/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -4,73 +4,68 @@ Last Updated 2026-01-08
|
|||||||
|
|
||||||
These rules MUST be followed at all times, it is very important.
|
These rules MUST be followed at all times, it is very important.
|
||||||
|
|
||||||
* Never use `git add -A` - add specific changes to a deliberate commit. A
|
- Never use `git add -A` - add specific changes to a deliberate commit. A commit
|
||||||
commit should contain one change. After each change, make a commit with a
|
should contain one change. After each change, make a commit with a good
|
||||||
good one-line summary.
|
one-line summary.
|
||||||
|
|
||||||
* NEVER modify the linter config without asking first.
|
- NEVER modify the linter config without asking first.
|
||||||
|
|
||||||
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
- NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||||
without asking first. In almost all cases, the code should be changed,
|
without asking first. In almost all cases, the code should be changed, NOT the
|
||||||
NOT the tests. If you think the test needs to be changed, make your case
|
tests. If you think the test needs to be changed, make your case for that and
|
||||||
for that and ask for permission to proceed, then stop. You need explicit
|
ask for permission to proceed, then stop. You need explicit user approval to
|
||||||
user approval to modify existing tests. (You do not need user approval
|
modify existing tests. (You do not need user approval for writing NEW tests.)
|
||||||
for writing NEW tests.)
|
|
||||||
|
|
||||||
* When linting, assume the linter config is CORRECT, and that each item
|
- When linting, assume the linter config is CORRECT, and that each item output
|
||||||
output by the linter is something that legitimately needs fixing in the
|
by the linter is something that legitimately needs fixing in the code.
|
||||||
code.
|
|
||||||
|
|
||||||
* When running tests, use `make test`.
|
- When running tests, use `make test`.
|
||||||
|
|
||||||
* Before commits, run `make check`. This runs `make lint` and `make test`
|
- Before commits, run `make check`. This runs `make lint` and `make test` and
|
||||||
and `make check-fmt`. Any issues discovered MUST be resolved before
|
`make check-fmt`. Any issues discovered MUST be resolved before committing
|
||||||
committing unless explicitly told otherwise.
|
unless explicitly told otherwise.
|
||||||
|
|
||||||
* When fixing a bug, write a failing test for the bug FIRST. Add
|
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate
|
||||||
appropriate logging to the test to ensure it is written correctly. Commit
|
logging to the test to ensure it is written correctly. Commit that. Then go
|
||||||
that. Then go about fixing the bug until the test passes (without
|
about fixing the bug until the test passes (without modifying the test
|
||||||
modifying the test further). Then commit that.
|
further). Then commit that.
|
||||||
|
|
||||||
* When adding a new feature, do the same - implement a test first (TDD). It
|
- When adding a new feature, do the same - implement a test first (TDD). It
|
||||||
doesn't have to be super complex. Commit the test, then commit the
|
doesn't have to be super complex. Commit the test, then commit the feature.
|
||||||
feature.
|
|
||||||
|
|
||||||
* When adding a new feature, use a feature branch. When the feature is
|
- When adding a new feature, use a feature branch. When the feature is
|
||||||
completely finished and the code is up to standards (passes `make check`)
|
completely finished and the code is up to standards (passes `make check`) then
|
||||||
then and only then can the feature branch be merged into `main` and the
|
and only then can the feature branch be merged into `main` and the branch
|
||||||
branch deleted.
|
deleted.
|
||||||
|
|
||||||
* Write godoc documentation comments for all exported types and functions as
|
- Write godoc documentation comments for all exported types and functions as you
|
||||||
you go along.
|
go along.
|
||||||
|
|
||||||
* ALWAYS be consistent in naming. If you name something one thing in one
|
- ALWAYS be consistent in naming. If you name something one thing in one place,
|
||||||
place, name it the EXACT SAME THING in another place.
|
name it the EXACT SAME THING in another place.
|
||||||
|
|
||||||
* Be descriptive and specific in naming. `wl` is bad;
|
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
|
||||||
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
||||||
`MaxConnectionsPerHost` is good.
|
|
||||||
|
|
||||||
* This is not prototype or teaching code - this is designed for production.
|
- This is not prototype or teaching code - this is designed for production. Any
|
||||||
Any security issues (such as denial of service) or other web
|
security issues (such as denial of service) or other web vulnerabilities are
|
||||||
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
P1 bugs and must be added to TODO.md at the top.
|
||||||
|
|
||||||
* As this is production code, no stubbing of implementations unless
|
- As this is production code, no stubbing of implementations unless specifically
|
||||||
specifically instructed. We need working implementations.
|
instructed. We need working implementations.
|
||||||
|
|
||||||
* NEVER silently fall back to a different setting when a user's parameter
|
- NEVER silently fall back to a different setting when a user's parameter
|
||||||
explicitly specifies a value. If a user requests format=webp and WebP
|
explicitly specifies a value. If a user requests format=webp and WebP encoding
|
||||||
encoding is not supported, return an error - do NOT silently output PNG
|
is not supported, return an error - do NOT silently output PNG instead. If a
|
||||||
instead. If a user specifies fit=invalid and that fit mode doesn't exist,
|
user specifies fit=invalid and that fit mode doesn't exist, return an error -
|
||||||
return an error - do NOT silently default to "cover". Silent fallbacks
|
do NOT silently default to "cover". Silent fallbacks violate the principle of
|
||||||
violate the principle of least surprise and mask bugs. The only acceptable
|
least surprise and mask bugs. The only acceptable defaults are for OMITTED
|
||||||
defaults are for OMITTED parameters, never for INVALID explicit values.
|
parameters, never for INVALID explicit values.
|
||||||
|
|
||||||
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
- Avoid vendoring deps unless specifically instructed to. NEVER commit the
|
||||||
the vendor directory, NEVER commit compiled binaries. If these
|
vendor directory, NEVER commit compiled binaries. If these directories or
|
||||||
directories or files exist, add them to .gitignore (and commit the
|
files exist, add them to .gitignore (and commit the .gitignore) if they are
|
||||||
.gitignore) if they are not already in there. Keep the entire git
|
not already in there. Keep the entire git repository (with history) small -
|
||||||
repository (with history) small - under 20MiB, unless you specifically
|
under 20MiB, unless you specifically must commit larger files (e.g. test
|
||||||
must commit larger files (e.g. test fixture example media files). Only
|
fixture example media files). Only OUR source code and immediately supporting
|
||||||
OUR source code and immediately supporting files (such as test examples)
|
files (such as test examples) goes into the repo/history.
|
||||||
goes into the repo/history.
|
|
||||||
|
|||||||
-1267
File diff suppressed because it is too large
Load Diff
+77
-37
@@ -1,54 +1,82 @@
|
|||||||
# Lint stage
|
# Lint phase. script/lint builds it alone. The linter is run directly:
|
||||||
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
|
# `make lint` and script/lint are themselves a docker build.
|
||||||
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
|
# golangci/golangci-lint:v2.12.2, 2026-10-04
|
||||||
|
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
|
# The linter compiles every package, and govips needs the libvips
|
||||||
|
# headers for that. REPO_POLICIES.md has the lint phase install them
|
||||||
|
# itself; this image is Debian, so with apt-get rather than apk.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase. script/test builds it alone.
|
||||||
|
# golang:1.25.4-alpine, 2026-02-25
|
||||||
|
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
# script/bootstrap --cgo installs the build dependencies (a C compiler
|
||||||
|
# and the libvips and libheif headers) and downloads the Go modules.
|
||||||
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN script/bootstrap --cgo
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter
|
# Without -v first; on a failure, again with -v for the details, and
|
||||||
RUN make fmt-check
|
# the step fails even if the second run passes.
|
||||||
RUN make lint
|
RUN 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
|
# Build stage. Nothing is wanted from the two phases above: these copies
|
||||||
|
# make BuildKit build them first, so this stage runs only when lint and
|
||||||
|
# test passed.
|
||||||
# golang:1.25.4-alpine, 2026-02-25
|
# golang:1.25.4-alpine, 2026-02-25
|
||||||
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
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
|
||||||
ARG VERSION=dev
|
|
||||||
|
|
||||||
# Install build dependencies for CGO image libraries
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
build-base \
|
|
||||||
vips-dev \
|
|
||||||
libheif-dev \
|
|
||||||
pkgconfig
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
# Build dependencies and Go modules, as in the test phase
|
||||||
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN GOTOOLCHAIN=auto go mod download
|
RUN script/bootstrap --cgo
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run tests
|
# VERSION is declared here, not earlier: a new value reruns only the
|
||||||
RUN make test
|
# build, not script/bootstrap. Given none, the version is
|
||||||
|
# `git describe --tags --always` of the .git in the build context (git
|
||||||
|
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
|
||||||
|
# after one, the short commit when no tag is reachable. A context that
|
||||||
|
# carries .git and still yields no version fails the build; one without
|
||||||
|
# .git, as from a source tarball, stamps an empty version. CGO stays
|
||||||
|
# enabled for govips; -trimpath keeps build paths out of the binary, and
|
||||||
|
# -s -w leave out the symbol table and debug information.
|
||||||
|
ARG VERSION
|
||||||
|
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
||||||
|
[ "$version" = unknown ]; }; then \
|
||||||
|
echo "the build context carries .git but yields no version" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.Version=${version}" \
|
||||||
|
-o /pixad ./cmd/pixad
|
||||||
|
|
||||||
# Build with CGO enabled
|
# Runtime stage, and the last one: a plain `docker build .` builds this
|
||||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
# stage and what it depends on, and nothing else.
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# alpine:3.21, 2026-02-25
|
# alpine:3.21, 2026-02-25
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
@@ -57,22 +85,34 @@ RUN apk add --no-cache \
|
|||||||
vips \
|
vips \
|
||||||
libheif \
|
libheif \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata
|
tzdata \
|
||||||
|
su-exec
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /pixad /usr/local/bin/pixad
|
COPY --from=builder /pixad /usr/local/bin/pixad
|
||||||
|
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create non-root user, config directory, and data directory
|
# Create non-root user, config directory, and data directory. pixad
|
||||||
RUN adduser -D -H -s /sbin/nologin pixad && \
|
# gets uid and gid 65532, which host login and system accounts do not
|
||||||
|
# use: a bind-mounted /var/lib/pixa is given to pixad, and on the host
|
||||||
|
# it must not belong to a person's account.
|
||||||
|
RUN addgroup -g 65532 pixad && \
|
||||||
|
adduser -D -H -s /sbin/nologin -u 65532 -G pixad pixad && \
|
||||||
mkdir -p /var/lib/pixa /etc/pixa && \
|
mkdir -p /var/lib/pixa /etc/pixa && \
|
||||||
chown pixad:pixad /var/lib/pixa
|
chown pixad:pixad /var/lib/pixa
|
||||||
|
|
||||||
# Copy default config (edit signing_key before use)
|
# No USER: the entrypoint must start as root to give a bind-mounted
|
||||||
COPY config.example.yml /etc/pixa/config.yml
|
# /var/lib/pixa to pixad; it then runs the server as pixad.
|
||||||
|
|
||||||
USER pixad
|
|
||||||
WORKDIR /var/lib/pixa
|
WORKDIR /var/lib/pixa
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/pixad", "--config", "/etc/pixa/config.yml"]
|
# Shell form so the probe follows PORT; a port set only in a mounted
|
||||||
|
# config file is not seen here.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --spider -q "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
|
||||||
|
|
||||||
|
# Settings come from PORT and the PIXA_ environment variables; only
|
||||||
|
# PIXA_SIGNING_KEY is required. A config file mounted at
|
||||||
|
# /etc/pixa/config.yml is optional and is read when present.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
.PHONY: check lint test fmt fmt-check build clean docker 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 =
|
||||||
NIX_RUN_SUFFIX =
|
NIX_RUN_SUFFIX =
|
||||||
else
|
else
|
||||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run '
|
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run '
|
||||||
NIX_RUN_SUFFIX = '
|
NIX_RUN_SUFFIX = '
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Default target: run all checks
|
# Default target: run all checks
|
||||||
check: fmt-check lint test
|
check:
|
||||||
|
@script/check
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@script/bootstrap
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
# Check formatting without modifying files
|
# Check formatting without modifying files
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@echo "Checking formatting..."
|
@script/fmt-check
|
||||||
@test -z "$$(gofmt -l . | grep -v '^vendor/')" || (echo "Files need formatting:"; gofmt -l . | grep -v '^vendor/'; exit 1)
|
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
fmt:
|
fmt:
|
||||||
@echo "Formatting code..."
|
@script/fmt
|
||||||
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
|
|
||||||
|
|
||||||
# Run linter
|
# Run linter (the lint phase of the Dockerfile)
|
||||||
lint:
|
lint:
|
||||||
@echo "Running linter..."
|
@script/lint
|
||||||
$(NIX_RUN_PREFIX)golangci-lint run$(NIX_RUN_SUFFIX)
|
|
||||||
|
|
||||||
# Run tests (30-second timeout)
|
# Run tests (the test phase of the Dockerfile)
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@script/test
|
||||||
$(NIX_RUN_PREFIX)CGO_ENABLED=1 go test -timeout 30s -v ./...$(NIX_RUN_SUFFIX)
|
|
||||||
|
|
||||||
# Build the binary
|
# Build the binary
|
||||||
build:
|
build:
|
||||||
@@ -47,20 +50,34 @@ clean:
|
|||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
rm -rf ./data
|
rm -rf ./data
|
||||||
|
|
||||||
# Build Docker image
|
# Build Docker image (tagged via script/projectname)
|
||||||
docker:
|
docker:
|
||||||
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
@script/docker
|
||||||
|
|
||||||
# Run tests in Docker (needed for CGO/libvips)
|
# Build the image, start it, and wait for its healthcheck (needs Docker;
|
||||||
|
# not part of check)
|
||||||
|
docker-smoke:
|
||||||
|
@script/docker-smoke
|
||||||
|
|
||||||
|
# Measure throughput, latency and peak memory with the default duration and
|
||||||
|
# number of clients (needs Docker and Go; a benchmark, not part of check)
|
||||||
|
loadtest:
|
||||||
|
@script/loadtest
|
||||||
|
|
||||||
|
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
||||||
|
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 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
|
||||||
@@ -70,6 +87,4 @@ devserver-stop:
|
|||||||
|
|
||||||
# Install pre-commit hook
|
# Install pre-commit hook
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@script/install-precommit
|
||||||
@printf 'make check\n' >> .git/hooks/pre-commit
|
|
||||||
@chmod +x .git/hooks/pre-commit
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# pixa
|
# pixa
|
||||||
|
|
||||||
pixa is a GPL-3.0-licensed Go web server by
|
pixa is a GPL-3.0-licensed Go web server by [@sneak](https://sneak.berlin) that
|
||||||
[@sneak](https://sneak.berlin) that proxies images from upstream
|
proxies images from upstream sources, optionally resizing or transforming them,
|
||||||
sources, optionally resizing or transforming them, and serves the
|
and serves the results. Both source and transformed images are cached to disk so
|
||||||
results. Both source and transformed images are cached to disk so that
|
that subsequent requests are served without origin fetches or additional
|
||||||
subsequent requests are served without origin fetches or additional
|
|
||||||
processing.
|
processing.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -15,64 +14,356 @@ git clone https://git.eeqj.de/sneak/pixa.git
|
|||||||
cd pixa
|
cd pixa
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# run with a config file
|
# run with a config file: copy the example and set a real signing key
|
||||||
./bin/pixad --config config.example.yml
|
# (the example placeholder is refused at startup), e.g. with
|
||||||
|
# openssl rand -base64 32
|
||||||
|
cp configs/config.example.yml config.yml
|
||||||
|
$EDITOR config.yml # replace the signing_key placeholder
|
||||||
|
./bin/pixad --config config.yml
|
||||||
|
|
||||||
# or build and run via Docker
|
# or build and run via Docker
|
||||||
make docker
|
make docker
|
||||||
docker run -p 8080:8080 pixad:latest
|
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A container takes its settings from environment variables (see Configuration
|
||||||
|
below for the list). Only `PIXA_SIGNING_KEY` is required; if it is unset the
|
||||||
|
container exits at startup naming the variable. Everything else has a built-in
|
||||||
|
default. A config file mounted at `/etc/pixa/config.yml` is optional: it is read
|
||||||
|
when present, and an environment variable wins over the same setting in it.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
pixa listens on plain HTTP and runs behind a reverse proxy that terminates TLS.
|
||||||
|
[`configs/Caddyfile`](configs/Caddyfile) is an example for Caddy, chosen because
|
||||||
|
it is the smallest correct one: Caddy gets the TLS certificate itself and does
|
||||||
|
everything in this list without further settings. The reverse proxy must:
|
||||||
|
|
||||||
|
- terminate TLS, as the login and generator pages work only over HTTPS (see
|
||||||
|
Routes);
|
||||||
|
- pass the `Host`, `Origin` and `Referer` headers on unchanged, as pixa refuses
|
||||||
|
a form from those pages unless `Origin` or `Referer` names the host in `Host`,
|
||||||
|
builds encrypted URLs from `Host`, and checks `Referer` against
|
||||||
|
`referer_blocklist`;
|
||||||
|
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
||||||
|
the address pixa sees the proxy's requests come from, so the login limit
|
||||||
|
counts each client by its own address (see `trusted_proxies` under
|
||||||
|
Configuration);
|
||||||
|
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
||||||
|
longest pixa takes to fetch, convert and send an image.
|
||||||
|
|
||||||
|
It may also refuse `/metrics`, as the example does, so that only a scraper that
|
||||||
|
reaches pixa directly can read it; pixa itself asks for the metrics username and
|
||||||
|
password there.
|
||||||
|
|
||||||
|
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
||||||
|
allowlist, refuses upstream hosts with private or local addresses, limits login
|
||||||
|
attempts, upstream response size and image dimensions, and sends the security
|
||||||
|
headers, `Strict-Transport-Security` included, with every response.
|
||||||
|
|
||||||
|
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
||||||
|
database and the disk cache:
|
||||||
|
|
||||||
|
- It needs a persistent volume: without one, every restart starts with an empty
|
||||||
|
cache. In the container, the startup script gives the directory to the user
|
||||||
|
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
||||||
|
must be able to write the directory.
|
||||||
|
- `cache_max_bytes` limits the source and transformed images together. The
|
||||||
|
database, the metadata files, the `.meta` file beside each transformed image
|
||||||
|
and files still being written come on top, and eviction runs in the
|
||||||
|
background, so the cache can pass the limit for a while: leave room on the
|
||||||
|
volume beyond it.
|
||||||
|
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
||||||
|
time pixa starts, is 75% of the sum of the space free on the volume and the
|
||||||
|
space the cached images already take, so a restart keeps the limit the cache
|
||||||
|
had, but anything else that fills or frees space on the volume moves it.
|
||||||
|
|
||||||
|
A load balancer's health check can request `/.well-known/healthcheck.json`,
|
||||||
|
which answers 200 whenever pixa is running, in maintenance mode too (see
|
||||||
|
`maintenance_mode`).
|
||||||
|
|
||||||
|
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
||||||
|
progress and the images being processed 5 seconds to finish, and exits: with 0,
|
||||||
|
or with 1 when images were still being processed after those 5 seconds or
|
||||||
|
another part of pixa failed to stop. A request not finished by then is cut off.
|
||||||
|
`docker stop` waits 10 seconds before it kills the container.
|
||||||
|
|
||||||
|
Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
|
||||||
|
it uses libvips through CGO; building it also needs their development files,
|
||||||
|
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these,
|
||||||
|
as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which
|
||||||
|
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
|
||||||
|
Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but
|
||||||
|
none of the C libraries: the checks compile pixa in Docker. Docker itself must
|
||||||
|
already be installed.
|
||||||
|
|
||||||
|
## Running under upaas
|
||||||
|
|
||||||
|
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||||
|
|
||||||
|
- **Port:** pixa listens on container port `8080`.
|
||||||
|
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its database and
|
||||||
|
cache. Creating the host directory when it is missing is upaas's job, tracked
|
||||||
|
in https://git.eeqj.de/sneak/upaas/issues/235.
|
||||||
|
- **Environment variables:**
|
||||||
|
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs and
|
||||||
|
login, 32+ characters, for example from `openssl rand -base64 32`
|
||||||
|
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
||||||
|
comma-separated
|
||||||
|
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
||||||
|
default 75% of (free space + what the cache holds)
|
||||||
|
- the rest are in the table under Configuration below
|
||||||
|
- **Health check:** the image's `HEALTHCHECK` requests
|
||||||
|
`/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds
|
||||||
|
after a deploy and marks the deploy failed unless it is `healthy`. The probe
|
||||||
|
uses the port from `PORT` (default `8080`), so a port changed only in a
|
||||||
|
mounted config file is not seen by it: change the port with `PORT`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Image-heavy web applications need a fast, caching reverse proxy that
|
Image-heavy web applications need a fast, caching reverse proxy that can resize
|
||||||
can resize and transcode images on the fly. pixa fills that role as a
|
and transcode images on the fly. pixa fills that role as a single,
|
||||||
single, self-contained binary with no external runtime dependencies
|
self-contained binary with no external runtime dependencies beyond libvips. It
|
||||||
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
supports HMAC-SHA256 signed URLs with expiration to prevent abuse, and
|
||||||
prevent abuse, and whitelisted source hosts for open access.
|
allowlisted source hosts for open access.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
- **Source content**:
|
- **Source content**:
|
||||||
`<statedir>/cache/src-content/<ab>/<cd>/<sha256 of source content>`
|
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
|
||||||
- **Source metadata**:
|
- **Source metadata**:
|
||||||
`<statedir>/cache/src-metadata/<hostname>/<sha256 of path>.json`
|
`<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json` (host,
|
||||||
(fetch time, original headers, request, content hash)
|
path and query, content hash, upstream status and headers, fetch time)
|
||||||
- **Database**: `<statedir>/state.sqlite3` (SQLite)
|
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
|
||||||
- **Output documents**:
|
- **Transformed images**:
|
||||||
`<statedir>/cache/dst-content/<ab>/<cd>/<sha256 of output content>`
|
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`,
|
||||||
|
each with a `.meta` file beside it holding its content type
|
||||||
|
|
||||||
Multiple source paths may reference the same content blob; the
|
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
|
||||||
database tracks references rather than using filesystem refcounting.
|
name.
|
||||||
In-process caching of request-to-output mappings targets 1-5k r/s.
|
|
||||||
|
Multiple source paths may reference the same content blob; the database tracks
|
||||||
|
references rather than using filesystem refcounting.
|
||||||
|
|
||||||
|
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||||
|
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
||||||
|
transformed images most recently cached or served, so a cache hit on one of them
|
||||||
|
reads only the image file from disk and not the metadata file stored beside it.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
|
pixa answers these routes; any other path answers 404. A path in this list asked
|
||||||
|
with a method the list does not give answers 405, except `/static/<file>`, which
|
||||||
|
answers any method as it answers `GET`. A browser's CORS preflight request
|
||||||
|
(`OPTIONS` with `Origin` and `Access-Control-Request-Method` headers) to any
|
||||||
|
path under `/v1/` answers 200, in maintenance mode too.
|
||||||
|
|
||||||
|
- `GET /` — the login page, or the URL generator page with a login session (see
|
||||||
|
Encrypted URLs). Needs: nothing. Answers: 200.
|
||||||
|
- `POST /` — log in with the signing key typed into the login page. Needs: the
|
||||||
|
login page's form (below). Answers: 303 to `/` with a login session cookie
|
||||||
|
that lasts 30 days for the right key; 200 with the login page and an error for
|
||||||
|
a wrong key; 429 over the login limit (below).
|
||||||
|
- `POST /generate` — make an encrypted URL from the generator page's form.
|
||||||
|
Needs: a login session and the generator page's form (below); without a login
|
||||||
|
session it answers 303 to `/`. Answers: 200 with the page showing the URL; 400
|
||||||
|
with the page naming a field that is not valid; 500 when the URL cannot be
|
||||||
|
made.
|
||||||
|
- `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`.
|
||||||
|
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>` — an image, fetched,
|
||||||
|
resized and converted (below). Needs: a signature, unless the host is
|
||||||
|
allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches
|
||||||
|
the image's `ETag`; 400 for a URL or parameter that is not valid, or for the
|
||||||
|
format `auto` an `Accept` header that is not valid; 406 for the format `auto`
|
||||||
|
when `Accept` allows none of the formats it chooses from; 401 for a missing or
|
||||||
|
wrong signature, a missing `exp` or an `exp` in the past; 403 when the
|
||||||
|
request's `Referer` names a host in `referer_blocklist`, checked before the
|
||||||
|
signature, the cache and the upstream fetch; 403 when the upstream host, or a
|
||||||
|
host it redirects to, is `localhost`, ends in `.localhost` or `.local`, or has
|
||||||
|
an address in a blocked network (see `blocked_networks`); 502 when the
|
||||||
|
upstream answered with an error status, and for 5 minutes after that for the
|
||||||
|
same source URL; 503 when pixa is busy or in maintenance mode; 500 for any
|
||||||
|
other failure.
|
||||||
|
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||||
|
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
||||||
|
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
||||||
|
decrypt, or that asks for a size or fit that is not valid; 410 once it has
|
||||||
|
expired; 504 when the upstream has not sent its response headers within
|
||||||
|
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
||||||
|
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
||||||
|
406, 403, 502, 503 and 500, as for `/v1/image/`.
|
||||||
|
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
||||||
|
nothing. Answers: 200.
|
||||||
|
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||||
|
`uptime_seconds`, `uptime_human`, `version`, `appname` and `maintenance_mode`.
|
||||||
|
Needs: nothing. Answers: 200, always.
|
||||||
|
- `GET /static/<file>` — the stylesheet and script the login and generator pages
|
||||||
|
load. Needs: nothing. Answers: 200, or 404 for a file that does not exist.
|
||||||
|
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
|
||||||
|
authentication with `metrics.username` and `metrics.password`. Answers: 200;
|
||||||
|
401 without them; 404 when they are not set, as the route then does not exist.
|
||||||
|
|
||||||
|
Every response carries an `X-Request-ID` header holding the request's ID, which
|
||||||
|
a client can quote when reporting a problem: the request's own `X-Request-ID`,
|
||||||
|
as a reverse proxy in front of pixa may send, when it is at most 64 letters,
|
||||||
|
digits, `-`, `_` or `.`; otherwise a random one pixa makes for the request,
|
||||||
|
which tells nothing about the machine or the other requests. pixa's log line for
|
||||||
|
the request carries the same ID as `request_id`, and so do the lines it logs
|
||||||
|
when it fetches, converts and serves an image; the fetch sends it to the
|
||||||
|
upstream host as `X-Request-ID`.
|
||||||
|
|
||||||
|
Both `POST` routes accept only a form that pixa's own page served: the page puts
|
||||||
|
a token in the form and sets a cookie to match, and a request without both is
|
||||||
|
refused with 403, so another site cannot submit the form from a visitor's
|
||||||
|
browser. The login and generator pages are meant to be opened over HTTPS: while
|
||||||
|
`debug` is off, a form sent from a page opened over plain HTTP is refused with
|
||||||
|
403, and while it is on, so is one sent from a page opened over HTTPS. Plain
|
||||||
|
HTTP is for development on the browser's own machine: the login session cookie
|
||||||
|
is always marked `Secure`, and over plain HTTP a browser keeps such a cookie
|
||||||
|
only for its own machine (`localhost`), if at all. A form is also refused with
|
||||||
|
403 when the page's host is not the `Host` header pixa receives, so a reverse
|
||||||
|
proxy in front of pixa must pass that header on unchanged. A form body over 1
|
||||||
|
MiB is refused with 413. The image routes answer the errors listed for them with
|
||||||
|
JSON holding `error`, `status` and `timestamp`.
|
||||||
|
|
||||||
|
An image URL has this form:
|
||||||
|
|
||||||
```
|
```
|
||||||
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>
|
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
|
||||||
```
|
```
|
||||||
|
|
||||||
Images are only fetched from origins using TLS with valid certificates.
|
Images are only fetched from origins using TLS with valid certificates, unless
|
||||||
|
`allow_http` is set: then pixa fetches every image over plain HTTP, which is for
|
||||||
|
testing only.
|
||||||
|
|
||||||
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
A request whose query string cannot be decoded, or gives any parameter more than
|
||||||
|
once, is refused with 400.
|
||||||
|
|
||||||
|
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
|
||||||
|
`avif`, `gif`, or `auto` (below)
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
|
- `sig` and `exp`: the signature and its expiry, needed unless the host is
|
||||||
|
allowlisted (see Signature Specification)
|
||||||
|
- `q` and `fit`: the output quality and how the image is fitted to `<size>`,
|
||||||
|
both optional (values under Signature Specification). Both are part of what is
|
||||||
|
cached, so each value of either is a separate cached image.
|
||||||
|
|
||||||
|
With the format `auto`, pixa chooses the format for each request from its
|
||||||
|
`Accept` header, in this order:
|
||||||
|
|
||||||
|
1. AVIF, when the header names `image/avif`;
|
||||||
|
2. WebP, when it names `image/webp`;
|
||||||
|
3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names
|
||||||
|
allows it, or when there is no `Accept` header or it is empty.
|
||||||
|
|
||||||
|
An entry with `q=0` refuses its format; other `q` values do not change the
|
||||||
|
order. AVIF and WebP must be named, as clients that cannot show them also send
|
||||||
|
`image/*` and `*/*`. pixa never sends a format the client refused: when the
|
||||||
|
header allows none of the three, the answer is 406, and a header that does not
|
||||||
|
parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The
|
||||||
|
signature, or the token of an encrypted URL, covers `auto` itself, so one URL
|
||||||
|
serves every client. Each format chosen is cached as a separate image, and every
|
||||||
|
answer that depends on `Accept` (the image, a 304, and the 400 and 406 above)
|
||||||
|
carries `Vary: Accept`, so a shared cache keeps the formats apart too.
|
||||||
|
|
||||||
|
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
||||||
|
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age`
|
||||||
|
is the whole seconds left until then, at most one year, so no browser or proxy
|
||||||
|
cache keeps the image after pixa would refuse the URL. A URL with no expiry gets
|
||||||
|
one year. `immutable` only stops a client revalidating while its copy is fresh.
|
||||||
|
|
||||||
|
When several requests for the same image, size, format, quality and fit miss the
|
||||||
|
cache at once, they share one upstream fetch (or one read of the cached source)
|
||||||
|
and one transcode: the first request does the work, and the others wait for its
|
||||||
|
image or its error, holding no upstream connection or processing slot of their
|
||||||
|
own. A waiting request stops waiting when its own client goes away. The work
|
||||||
|
goes on for the others even if the first request's client goes away, until that
|
||||||
|
request's `downstream_timeout` ends. The shared fetch sends the first request's
|
||||||
|
ID upstream, and the lines logged for the fetch and the transcode carry that ID.
|
||||||
|
|
||||||
|
The login form (`POST /`) is limited to 5 attempts per minute per client
|
||||||
|
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||||
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
|
address comes from `X-Forwarded-For` only when the address pixa sees for
|
||||||
|
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
||||||
|
users behind the proxy are counted as one client. That address is not always the
|
||||||
|
proxy's own: a proxy on the Docker host that connects to pixa over `127.0.0.1`
|
||||||
|
is seen as the gateway of the container's Docker network, such as `172.17.0.1`
|
||||||
|
on the default bridge, and one that connects through another of the host's
|
||||||
|
addresses is seen with that address. To be sure, read it as `remoteIP` in pixa's
|
||||||
|
request log while it is not in `trusted_proxies` (see `trusted_proxies` under
|
||||||
|
Configuration). With the default `trusted_proxies` (the RFC 1918 ranges), a
|
||||||
|
client with a private address can choose the address it is counted by through
|
||||||
|
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
|
||||||
|
because its own address is trusted too. Setting `trusted_proxies` to only the
|
||||||
|
address pixa sees for requests that come through the proxy closes this.
|
||||||
|
|
||||||
|
### Encrypted URLs
|
||||||
|
|
||||||
|
An encrypted URL is an image URL made on pixa's own web page by someone who
|
||||||
|
knows the signing key. It works for any upstream host, allowlisted or not,
|
||||||
|
without a signature, and whoever gets it can neither read the source URL from it
|
||||||
|
nor change what it asks for.
|
||||||
|
|
||||||
|
1. Open `/` in a browser over HTTPS (or over plain HTTP while `debug` is on, see
|
||||||
|
Routes) and log in with the signing key (`signing_key`). The login session
|
||||||
|
lasts 30 days, or until `/logout`.
|
||||||
|
2. On the generator page, give the source image's URL, the width and height, the
|
||||||
|
format, quality and fit, and how long the URL lasts, then submit the form
|
||||||
|
(`POST /generate`). Width and height both empty or `0` keep the original
|
||||||
|
size; if only one of them is empty or `0`, that side is scaled to keep the
|
||||||
|
image's proportions.
|
||||||
|
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
||||||
|
it expires. `<host>` is the host the page was opened on, and the URL starts
|
||||||
|
with `http` instead while `debug` is on. The name after the token is ignored
|
||||||
|
and only gives the URL a file extension, `jpg` for `orig` and `auto`.
|
||||||
|
|
||||||
|
The token holds the source's host, path and query and the size, format, quality,
|
||||||
|
fit and expiry, encrypted with a key derived from `signing_key`. The source
|
||||||
|
URL's scheme is not kept: the image is fetched like any other (see Routes), and
|
||||||
|
the blocked networks still apply.
|
||||||
|
|
||||||
|
How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never.
|
||||||
|
The expiry is fixed in the token when the URL is made and cannot be changed or
|
||||||
|
revoked afterwards. Until then the image is served with a `max-age` that ends at
|
||||||
|
the expiry (see Routes); after it the URL answers 410 `URL has expired`. A URL
|
||||||
|
made to last forever stops working only when `signing_key` changes: changing it
|
||||||
|
makes every encrypted URL already handed out answer 400, and ends every login
|
||||||
|
session.
|
||||||
|
|
||||||
|
### Image Metadata
|
||||||
|
|
||||||
|
pixa decodes and re-encodes every image it serves, and removes all metadata from
|
||||||
|
the output: EXIF (GPS position, camera make, model and serial number, capture
|
||||||
|
time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be
|
||||||
|
turned off.
|
||||||
|
|
||||||
|
- The `orig` format means the source's own format, not the source's bytes: an
|
||||||
|
`orig` image is re-encoded and stripped like any other.
|
||||||
|
- An image with an EXIF orientation is turned upright first, so it displays the
|
||||||
|
same without the tag; a requested size applies to the upright image.
|
||||||
|
- An image with an ICC profile is converted to sRGB first, since clients show an
|
||||||
|
image with no profile as sRGB. Colours outside sRGB, such as the most
|
||||||
|
saturated ones in a Display P3 photo, are clipped.
|
||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
Source hosts may be whitelisted in the configuration. Non-whitelisted
|
Source hosts may be allowlisted in the configuration. Non-allowlisted hosts
|
||||||
hosts require an HMAC-SHA256 signature.
|
require an HMAC-SHA256 signature.
|
||||||
|
|
||||||
#### Signature Specification
|
#### Signature Specification
|
||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
Signatures use HMAC-SHA256 and include an expiration timestamp to prevent replay
|
||||||
prevent replay attacks.
|
attacks. Signatures are **exact match only**: every component (host, path,
|
||||||
|
query, dimensions, format, expiration, quality, fit) must match exactly what was
|
||||||
|
signed. No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
|
|
||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
```
|
```
|
||||||
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
@@ -82,38 +373,191 @@ Where:
|
|||||||
- `query` — source query string, empty string if none
|
- `query` — source query string, empty string if none
|
||||||
- `width` — requested width in pixels, `0` for original
|
- `width` — requested width in pixels, `0` for original
|
||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format, one of those listed under Routes, with `original`
|
||||||
- `expiration` — Unix timestamp when signature expires
|
signed as `orig` and `jpg` as `jpeg`; `auto` is signed as `auto`, not as the
|
||||||
|
format chosen for the request
|
||||||
|
- `expiration` — the URL's `exp` query parameter, the Unix timestamp when the
|
||||||
|
signature expires; a request whose `exp` is not a whole number, an empty
|
||||||
|
`exp=` included, is refused with 400
|
||||||
|
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100, or
|
||||||
|
`85` when the URL has no `q`; a request whose `q` is anything else is refused
|
||||||
|
with 400
|
||||||
|
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
||||||
|
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
||||||
|
anything else, an empty `fit=` included, is refused with 400
|
||||||
|
|
||||||
**Example:** resize
|
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet of
|
||||||
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
||||||
expiration 1704067200:
|
compares it exactly, so a signature encoded without padding, as Node's
|
||||||
|
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
|
||||||
|
|
||||||
|
**Example:** with the signing key `example-signing-key-for-documentation`,
|
||||||
|
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with expiration
|
||||||
|
1704067200, default quality and fit:
|
||||||
|
|
||||||
1. Build input:
|
1. Build input:
|
||||||
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
|
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
||||||
2. Compute HMAC-SHA256 with your secret key
|
2. Compute HMAC-SHA256 of it with the signing key
|
||||||
3. Base64URL-encode the result
|
3. Base64URL-encode the result, keeping the `=` padding:
|
||||||
|
`-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=`
|
||||||
4. URL:
|
4. URL:
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200`
|
||||||
|
|
||||||
**Whitelist patterns:**
|
For the same image at quality 40 with fit `contain`, the input ends in
|
||||||
|
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`,
|
||||||
|
and the URL is
|
||||||
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain`.
|
||||||
|
|
||||||
|
**Allowlist patterns:**
|
||||||
|
|
||||||
- **Exact match**: `cdn.example.com` — matches only that host
|
- **Exact match**: `cdn.example.com` — matches only that host
|
||||||
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
||||||
`images.example.com`, and `example.com`
|
`images.example.com`, and `example.com`
|
||||||
|
|
||||||
|
An IP address is matched exactly; write an IPv6 address without brackets. An
|
||||||
|
entry that is neither a host name (letters, digits, hyphens, underscores and
|
||||||
|
dots, with at most one leading dot) nor an IP address, such as one with a port
|
||||||
|
or a `*.` wildcard, aborts startup.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Configured via YAML file (`--config`). Key settings:
|
Every setting can be given as an environment variable, in a YAML config file
|
||||||
|
(`--config`), or both. A variable present in the environment wins over the file,
|
||||||
|
even when it is empty, and the file wins over the built-in default. The one
|
||||||
|
exception is a variable named in the file's `env:` section: it is set while the
|
||||||
|
file loads, so it overrides both the environment the process was started with
|
||||||
|
and the file's own key. A variable's value is parsed as the same text in the
|
||||||
|
file would be. The three lists take comma-separated entries, with the spaces
|
||||||
|
around each trimmed; an empty variable is an empty list. A value that does not
|
||||||
|
parse or is invalid aborts startup, naming the variable. A variable whose name
|
||||||
|
starts with `PIXA_` but is not in the table below, such as a misspelled one or
|
||||||
|
`PIXA_PORT`, aborts startup naming it, as an unknown config key does. The one
|
||||||
|
other accepted name is `PIXA_CONFIG_PATH`, the config file's path (like
|
||||||
|
`--config`). The variables set by the file's `env:` section are checked the same
|
||||||
|
way.
|
||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
pixa reads at most one config file: the one given with `--config` (or `-c`),
|
||||||
- `source_host_whitelist` — list of allowed upstream hosts
|
otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
|
||||||
- `upstream_max_response_size` — max origin response size
|
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml` and
|
||||||
- `downstream_timeout` — client response timeout
|
`config.yaml` in the working directory. A named file that does not exist, cannot
|
||||||
|
be read or does not parse aborts startup. Of the files pixa looks for on its
|
||||||
|
own, only one that does not exist is passed over, without a message. One that
|
||||||
|
pixa cannot read or parse aborts startup, naming the file. So does one in a
|
||||||
|
directory pixa may not enter, whether or not it is there, since pixa cannot
|
||||||
|
tell. With no file, pixa uses the environment and the defaults.
|
||||||
|
|
||||||
|
| Variable | Config key | Meaning |
|
||||||
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `PIXA_SIGNING_KEY` | `signing_key` | Required: secret for signed and encrypted URLs and login, 32+ characters |
|
||||||
|
| `PORT` | `port` | Port to listen on; default `8080` |
|
||||||
|
| `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
|
||||||
|
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
|
||||||
|
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of (free + cached) |
|
||||||
|
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
|
||||||
|
| `PIXA_REFERER_BLOCKLIST` | `referer_blocklist` | Hosts whose pages the image routes refuse with 403, by `Referer` |
|
||||||
|
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
|
||||||
|
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
||||||
|
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
||||||
|
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||||
|
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
||||||
|
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
||||||
|
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||||
|
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||||
|
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||||
|
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` |
|
||||||
|
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
||||||
|
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
||||||
|
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
||||||
|
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
|
||||||
|
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Answer image requests with 503; the health check stays 200; default `false` |
|
||||||
|
|
||||||
|
Key settings in more detail:
|
||||||
|
|
||||||
|
- `access_control_allow_origin` — the origin a browser lets read the responses
|
||||||
|
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
|
||||||
|
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
|
||||||
|
default, is any site; otherwise one `http` or `https` origin such as
|
||||||
|
`https://example.com`, whose host is a lowercase host name (letters, digits,
|
||||||
|
hyphens and dots, with a letter in its last part) or an IP address (IPv6 in
|
||||||
|
brackets, in its shortest form), with an optional port 1-65535 that has no
|
||||||
|
leading zero and is not the scheme's default. Any other value, including
|
||||||
|
another scheme such as a browser extension's, aborts startup
|
||||||
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
|
- `referer_blocklist` — list of hosts whose pages may not show pixa's images, to
|
||||||
|
stop other sites hotlinking them. Entries are written and matched as for
|
||||||
|
`allowlist_hosts` (see Allowlist patterns), and an entry that is neither a
|
||||||
|
host name nor an IP address aborts startup. A request to `/v1/image/` or
|
||||||
|
`/v1/e/` whose `Referer` header names a listed host is refused with 403 before
|
||||||
|
its signature or token is checked and before the cache or the upstream host is
|
||||||
|
used, so it fetches nothing, and it is refused even when the image is cached.
|
||||||
|
A request with no `Referer`, or one that does not parse as a URL with a host,
|
||||||
|
is served, as many clients send none. So this is easily got around: a site
|
||||||
|
whose pages send no `Referer` (for example with
|
||||||
|
`Referrer-Policy: no-referrer`) is not stopped. It does not apply to the login
|
||||||
|
and generator pages. Default: empty
|
||||||
|
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
|
||||||
|
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
|
||||||
|
benchmark, NAT64, and the like); an invalid CIDR aborts startup
|
||||||
|
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
|
||||||
|
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
|
||||||
|
of these ranges; the logged and login-recorded client address is then the
|
||||||
|
rightmost forwarded entry that is not itself a trusted proxy. Otherwise the
|
||||||
|
direct peer address is used and the header is ignored, so a client connecting
|
||||||
|
directly from an address outside these ranges cannot spoof its address. An
|
||||||
|
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a
|
||||||
|
private network; an explicitly empty list (`[]`) trusts no one, and an
|
||||||
|
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
||||||
|
to the address pixa sees for requests that come through your proxy, such as
|
||||||
|
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
||||||
|
(see the login limit under Routes). For a proxy on the Docker host that
|
||||||
|
connects to pixa over `127.0.0.1`, that address is the gateway of the
|
||||||
|
container's Docker network (`172.17.0.1` on the default bridge), not the
|
||||||
|
proxy's own address; a proxy that connects through another of the host's
|
||||||
|
addresses is seen with that address. To be sure which address it is, set this
|
||||||
|
to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the
|
||||||
|
proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||||
|
as a duration such as `30s` (the default) or `2m`
|
||||||
|
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||||
|
default `52428800` (50 MiB). It also limits the image data pixa decodes
|
||||||
|
- `downstream_timeout` — time allowed for answering one client request, as a
|
||||||
|
duration; default `60s`. The upstream fetch counts toward it, and so do the
|
||||||
|
waits for an upstream connection and for a processing slot (up to 10 seconds
|
||||||
|
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
||||||
- `signing_key` — HMAC secret for URL signatures
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
|
- `db_url` — the SQLite database to open; omitted, it is
|
||||||
|
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||||
|
database in WAL mode. pixa adds `_pragma=busy_timeout(5000)` to any `db_url`,
|
||||||
|
so a write that finds another in progress waits up to five seconds for it
|
||||||
|
instead of failing. WAL mode comes only from the URL: keep
|
||||||
|
`_pragma=journal_mode(WAL)` in one you set
|
||||||
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk
|
||||||
|
cache entirely; omitted defaults to 75% of the sum of the free space on the
|
||||||
|
filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||||
|
transformed images the cache already holds, worked out at startup (minimum 500
|
||||||
|
MiB)
|
||||||
|
- `upstream_connections` — the most connections to upstream hosts at once, all
|
||||||
|
hosts together, on top of `upstream_connections_per_host`; default `64`. A
|
||||||
|
fetch holds its connection until its image has been processed. A fetch that
|
||||||
|
finds all of them in use waits up to 10 seconds for one to free up; if none
|
||||||
|
does, and `downstream_timeout` has not ended first, the request is answered
|
||||||
|
503 with the error `server busy, try again later`
|
||||||
|
- `max_concurrent_processing` — the most images decoded and encoded at once;
|
||||||
|
default the number of CPUs pixa can use (`GOMAXPROCS`), which follows a
|
||||||
|
container's CPU limit. A request that finds all of them in use waits up to 10
|
||||||
|
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||||
|
ended first, it is answered 503 the same way
|
||||||
|
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||||
|
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
||||||
|
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
||||||
|
still answers 200 and reports `"maintenance_mode": true`. It stays 200 because
|
||||||
|
the image's Docker `HEALTHCHECK` requests it: a 503 there would make the
|
||||||
|
container unhealthy, and upaas marks a deploy failed when its container is
|
||||||
|
unhealthy. The login and URL generator pages and `/metrics` keep working
|
||||||
|
|
||||||
See `config.example.yml` for all options with defaults.
|
See `configs/config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@@ -122,9 +566,110 @@ See `config.example.yml` for all options with defaults.
|
|||||||
- **Image processing**: govips (CGO wrapper for libvips)
|
- **Image processing**: govips (CGO wrapper for libvips)
|
||||||
- **Database**: SQLite via modernc.org/sqlite
|
- **Database**: SQLite via modernc.org/sqlite
|
||||||
- **Static assets**: embedded via `//go:embed`
|
- **Static assets**: embedded via `//go:embed`
|
||||||
- **Metrics**: Prometheus
|
- **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics
|
||||||
|
(duration, response size, requests in flight) and the Go runtime and process
|
||||||
|
metrics; requests are measured and `/metrics` is served only when
|
||||||
|
`metrics.username` and `metrics.password` are set
|
||||||
- **Logging**: stdlib slog
|
- **Logging**: stdlib slog
|
||||||
|
|
||||||
|
## Entrypoints
|
||||||
|
|
||||||
|
This repository adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
|
provide:
|
||||||
|
|
||||||
|
- `script/bootstrap` — install git, make, Go, Node, Yarn and prettier and
|
||||||
|
download the Go modules (idempotent); with `--cgo`, the C compiler and the
|
||||||
|
libvips and libheif libraries that compiling pixa needs instead of Node, Yarn
|
||||||
|
and prettier
|
||||||
|
- `script/setup` — make a fresh clone ready for development (bootstrap, then
|
||||||
|
install-precommit)
|
||||||
|
- `script/projectname` — output the project name ("pixa")
|
||||||
|
- `script/test` — run the test suite: build the `test` phase of the
|
||||||
|
`Dockerfile`, tagged `pixa-test`
|
||||||
|
- `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`,
|
||||||
|
tagged `pixa-lint`; the linter never runs on the host
|
||||||
|
- `script/fmt` — format the Go code with gofmt and the markdown with prettier
|
||||||
|
(writes)
|
||||||
|
- `script/fmt-check` — check the same formatting (read-only), on the host
|
||||||
|
- `script/check` — run test, lint, and fmt-check
|
||||||
|
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
||||||
|
the version from `git describe`; the image's build stage depends on the `lint`
|
||||||
|
and `test` phases, so this runs them too
|
||||||
|
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
||||||
|
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a
|
||||||
|
benchmark, not part of `script/check` (see Load Test)
|
||||||
|
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`),
|
||||||
|
then `script/check`, then build the image as `script/docker` does
|
||||||
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
|
`script/check`)
|
||||||
|
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||||
|
`script/precommit`
|
||||||
|
|
||||||
|
Every `docker build` in these scripts passes `--no-cache`, so the lint and test
|
||||||
|
phases run on every build instead of coming from the build cache.
|
||||||
|
`script/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`,
|
||||||
|
`script/setup` and `script/install-precommit` are the standard copies from
|
||||||
|
`sneak/prompts`, kept identical to them. `script/fmt` and `script/fmt-check` are
|
||||||
|
the standard copies with pixa's `gofmt` step kept before prettier. prettier
|
||||||
|
formats the markdown only: not the HTML templates, as it cannot parse a Go
|
||||||
|
template action inside a tag, and not `REPO_POLICIES.md` (see
|
||||||
|
`.prettierignore`), a copy of the one in `sneak/prompts`.
|
||||||
|
|
||||||
|
## Load Test
|
||||||
|
|
||||||
|
`script/loadtest` (or `make loadtest`) measures how fast pixad answers and how
|
||||||
|
much memory it uses. It is a benchmark, not a check: `script/check` does not run
|
||||||
|
it. It needs Docker and Go.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
script/loadtest # 10 seconds per scenario, 4 clients
|
||||||
|
script/loadtest 30s 32 # 30 seconds per scenario, 32 clients
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds the image with `script/docker` and the load tool,
|
||||||
|
[vegeta](https://github.com/tsenart/vegeta), from a pinned commit. Each scenario
|
||||||
|
starts a new pixad container and a new origin container, `cmd/loadtest-origin`:
|
||||||
|
an upstream host that answers every path with the same generated 1600x1200 JPEG.
|
||||||
|
vegeta then sends requests from the given number of clients, each sending its
|
||||||
|
next request as soon as its last one is answered, all for an image resized to
|
||||||
|
400x300 WebP:
|
||||||
|
|
||||||
|
- `hit`: the same image every time, put in the cache first;
|
||||||
|
- `miss`: a new source image every time, so pixad fetches and converts each one;
|
||||||
|
- `herd`: each new source image once per client in a row, so that all clients
|
||||||
|
ask for it at the same time and share one fetch and one conversion (see
|
||||||
|
Routes).
|
||||||
|
|
||||||
|
pixad refuses upstream hosts with private or local addresses, so the containers
|
||||||
|
share a Docker network in `203.0.113.0/24`, a range set aside for documentation.
|
||||||
|
A second run on the same Docker host while one is going fails, as it cannot
|
||||||
|
create that network.
|
||||||
|
|
||||||
|
For each scenario the script prints vegeta's report and two lines of its own:
|
||||||
|
|
||||||
|
- `Requests [total, rate, throughput]`: the requests sent, how many were sent
|
||||||
|
per second, and how many were answered successfully per second; the last is
|
||||||
|
the number to compare with the target under Storage;
|
||||||
|
- `Latencies [min, mean, 50, 90, 95, 99, max]`: the time from sending a request
|
||||||
|
to the end of its answer; `50`, `95` and `99` are the 50th, 95th and 99th
|
||||||
|
percentiles;
|
||||||
|
- `Status Codes` and `Error Set`: anything other than `200` means the other
|
||||||
|
numbers are not for the scenario described, such as `503` when pixad was busy;
|
||||||
|
- `Bytes In`: `0`, as vegeta is told not to keep the images it receives;
|
||||||
|
- `pixad peak memory (VmHWM)`: the peak resident memory of pixad's process since
|
||||||
|
its container started, in kB; for `hit` it includes the request that put the
|
||||||
|
image in the cache;
|
||||||
|
- `requests to the origin`: the fetches pixad made: one for `hit`, one per
|
||||||
|
request for `miss`, and one per image for `herd`, that is the requests sent
|
||||||
|
divided by the number of clients.
|
||||||
|
|
||||||
|
The numbers depend on the machine and on whatever else runs on it. The first
|
||||||
|
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
|
||||||
|
nothing about the target.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
See [TODO.md](TODO.md) for the full prioritized task list.
|
See [TODO.md](TODO.md) for the full prioritized task list.
|
||||||
|
|||||||
+448
-27
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-09-08
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -34,10 +34,57 @@ style conventions are in separate documents:
|
|||||||
every file before committing. There are zero exceptions to this rule.
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an executable
|
||||||
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||||
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||||
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||||
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||||
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||||
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||||
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||||
|
for development after a fresh clone: runs `bootstrap`, then
|
||||||
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||||
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
|
installed node if present; otherwise it installs a PINNED node version via
|
||||||
|
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||||
|
release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
|
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||||
|
docker and git then gets through `script/check`. Four further scripts are our
|
||||||
|
own extensions to the standard: `script/check` runs `script/test`,
|
||||||
|
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||||
|
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||||
|
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||||
|
`script/projectname` (literally that filename) simply outputs the project's
|
||||||
|
name. Scripts that need the name call `script/projectname` — e.g.
|
||||||
|
`script/docker` assembles its image tag from it — so those scripts stay
|
||||||
|
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||||
|
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||||
|
the hook itself. Model scripts are at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
|
README requirements below).
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
@@ -53,15 +100,140 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled.
|
Dockerfiles install development prerequisites by running `script/bootstrap`
|
||||||
|
rather than duplicating installs inline; COPY `script/` and the dependency
|
||||||
|
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
|
||||||
|
running it.
|
||||||
|
|
||||||
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
|
and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||||
|
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||||
|
```
|
||||||
|
|
||||||
|
**A stage that is not the last one in the file is built only when the final
|
||||||
|
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||||
|
two gates are always invoked by name here, and why the final stage carries a
|
||||||
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||||
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
|
and tested nothing.
|
||||||
|
|
||||||
|
**Every `docker build` in `script/` is tagged**, here and in
|
||||||
|
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||||
|
image behind on every invocation, on every developer host and every CI
|
||||||
|
runner; a tagged one replaces the previous image.
|
||||||
|
|
||||||
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
|
themselves a `docker build` and would recurse into a daemon that does not
|
||||||
|
exist in a build step. Formatting is the exception and stays on the host:
|
||||||
|
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||||
|
read-only twin.
|
||||||
|
|
||||||
|
**No lint verdict may come from a host invocation of the linter.** On a
|
||||||
|
shared host golangci-lint reads a result cache keyed on file content rather
|
||||||
|
than location, so a second checkout of the same content is served the first
|
||||||
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
|
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||||
|
a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||||
|
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||||
|
the check `RUN` is served from cache, nothing executes, and the build still
|
||||||
|
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||||
|
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||||
|
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||||
|
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||||
|
not evidence that anything ran: a sub-second build reporting success is a
|
||||||
|
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||||
|
and friends destroy a build cache shared with every other build on the host.
|
||||||
|
|
||||||
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
|
and the test phase is based on the Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Lint phase
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage, and the last one
|
||||||
|
FROM alpine@sha256:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
|
both Go and the linter), so nothing needs installing.
|
||||||
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
|
lines a red gate would not fail the build.
|
||||||
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
|
and nothing else.
|
||||||
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
|
create placeholder files so the embed directives resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
|
`vips-dev`), install them in the lint phase with `apk add`.
|
||||||
|
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by
|
||||||
|
`script/docker` and `script/cibuild`; no stage may call `git describe`.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
a successful build implies all checks pass.
|
That script 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 is uncached and so runs the gate phases a second time. That is the
|
||||||
|
price of the rule above, and it is worth paying: the image that ships is built
|
||||||
|
from a run of its own gates rather than from a cache entry.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -69,9 +241,11 @@ style conventions are in separate documents:
|
|||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
target to install the pre-commit hook.
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||||
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||||
|
that shims to it.
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
- All repos with software must have tests that run via the platform-standard
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
@@ -79,8 +253,64 @@ 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.
|
||||||
|
|
||||||
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
|
detail on failure (every test case, every assertion). The command lives in the
|
||||||
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||||
|
Makefile form below is the same pattern for any repo-local invocation:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so the target cannot report a pass it did not earn, and the rerun
|
||||||
|
reproduces a failure instead of replaying it. It leaves the build cache
|
||||||
|
alone, so it costs the runtime of the suite and no recompilation.
|
||||||
|
|
||||||
|
Note that this is a second, independent cache, stacked below the Docker
|
||||||
|
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
|
||||||
|
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
|
||||||
|
it does not guarantee `go test` inside that step does any work, because the
|
||||||
|
`GOCACHE` baked into earlier image layers survives into the re-executed
|
||||||
|
step. They are two separate defects requiring two separate fixes, and a fix
|
||||||
|
for one must not be recorded as covering the other.
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||||
|
already proved the tests are broken, so the build must not pass even if a
|
||||||
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
|
for diagnostic output.
|
||||||
|
|
||||||
- Docker builds must complete in under 5 minutes.
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
@@ -93,10 +323,90 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
a new repo.
|
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||||
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||||
|
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||||
|
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||||
|
therefore excludes only the copies at the repository root, while `config/.env`
|
||||||
|
and `certs/server.key` still reach the context and can land in an image layer
|
||||||
|
— which is more dangerous than a short file with no secret patterns at all,
|
||||||
|
because it reads as solved and stops anyone looking. Give every
|
||||||
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
|
root-anchored entries unprefixed: `.git`, and the repo's own host-built
|
||||||
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||||
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||||
|
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||||
|
also catches something the build needs, re-include it with a negation
|
||||||
|
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||||
|
other file it covers. Fetch the standard `.dockerignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
|
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||||
|
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||||
|
additional checkout of the repo — so under `COPY . .` the build context
|
||||||
|
inflates by a multiple of the repo and another session's unreviewed work can
|
||||||
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||||
|
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||||
|
prefix, because the prefixed form would also delete any nested directory of
|
||||||
|
that name from the build. Anchoring carries a known gap that the canonical
|
||||||
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||||
|
file and not the tracker: the directory is created in the agent's working
|
||||||
|
directory, so a repo running agents in subdirectories still ships
|
||||||
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
|
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
|
||||||
|
it fails quietly there.** In a build stage there is no repository, so
|
||||||
|
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
|
||||||
|
the binary reports no version at all, and the build still exits 0. Compute the
|
||||||
|
version on the host and thread it in as a build arg. `script/docker` and
|
||||||
|
`script/cibuild` do this, byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Own line: a failing command substitution inside an argument does not
|
||||||
|
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$(script/projectname)" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||||
|
fallback is applied — a live check that fires on a build from an export with
|
||||||
|
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||||
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
|
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
|
||||||
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||||
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
|
the scripts stay byte-identical. One consequence for CI: the standard
|
||||||
|
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||||
|
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
|
transfers only the delta from the previous build.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
@@ -105,9 +415,45 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||||
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||||
|
because it cannot be written once for every repo: the `deny` list of the
|
||||||
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
|
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
|
||||||
|
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
|
||||||
|
only pin, since no repo installs golangci-lint on the host: bumping the
|
||||||
|
version means changing it and nothing else.
|
||||||
|
|
||||||
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
|
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||||
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||||
|
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||||
|
disagree about what the tool even is. The canonical form:
|
||||||
|
- compares the installed version against the pin over the **whole** version
|
||||||
|
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||||
|
running `2.12.2-rc1` and skips the install;
|
||||||
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
|
skipped one;
|
||||||
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||||
|
then through `PATH`, not through the directory the installer wrote to —
|
||||||
|
and fails naming the resolved path, since an install that a shadowing
|
||||||
|
binary hides succeeds while changing nothing any caller sees;
|
||||||
|
- is actually called, and prints the version on both success paths: a
|
||||||
|
function defined and never invoked has the same exit status and the same
|
||||||
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -121,12 +467,76 @@ style conventions are in separate documents:
|
|||||||
- Dockerized web services listen on port 8080 by default, overridable with
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
`PORT`.
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
- `README.md` is the primary documentation. Required sections:
|
- `README.md` is the primary documentation. Required sections:
|
||||||
- **Description**: First line must include the project name, purpose,
|
- **Description**: First line must include the project name, purpose,
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
- **Rationale**: Why does this exist?
|
- **Rationale**: Why does this exist?
|
||||||
- **Design**: How is the program structured?
|
- **Design**: How is the program structured?
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
@@ -144,8 +554,14 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
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.
|
||||||
@@ -156,7 +572,9 @@ style conventions are in separate documents:
|
|||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
subdirectory names:
|
subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
`cmd/`
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -175,6 +593,9 @@ style conventions are in separate documents:
|
|||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
- `Makefile`
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
- `Dockerfile`, `.dockerignore`
|
- `Dockerfile`, `.dockerignore`
|
||||||
- `.gitea/workflows/check.yml`
|
- `.gitea/workflows/check.yml`
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
|||||||
@@ -1,65 +1,682 @@
|
|||||||
# Pixa 1.0 TODO
|
# Workflow
|
||||||
|
|
||||||
Remaining tasks sorted by priority for a working 1.0 release.
|
- branch per issue from `next`
|
||||||
|
- do the work in Next Step
|
||||||
|
- move Next Step to the top of Completed Steps
|
||||||
|
- `TODO.md` merges with git's union merge (`.gitattributes`), which never
|
||||||
|
reports a conflict: read the merged entries after every merge or rebase
|
||||||
|
- move the top item of Future Steps into Next Step
|
||||||
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
- open a PR based on `next`
|
||||||
|
- an independent reviewer who did not write the change gates it
|
||||||
|
- the manager squash-merges the PR into `next` once review passes
|
||||||
|
- `next` stays green and mergeable to `main` at any time; only the owner merges
|
||||||
|
`next` into `main`, via the single milestone PR
|
||||||
|
- push
|
||||||
|
|
||||||
## P0: Critical for 1.0
|
# Status
|
||||||
|
|
||||||
### Image Processing
|
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on
|
||||||
- [x] Add WebP encoding support (currently returns error)
|
`next`, and `main` receives only the milestone PR that the owner merges. `next`
|
||||||
- [x] Add AVIF encoding support (implemented via govips)
|
is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work
|
||||||
|
extracted the internal/magic, internal/allowlist, internal/httpfetcher, and
|
||||||
|
internal/signature packages. The gosec findings from the 2026-07-06 survey are
|
||||||
|
resolved. The disk cache is now size-bounded with LRU eviction
|
||||||
|
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
|
||||||
|
|
||||||
### Manual Testing (verify auth/encrypted URLs work)
|
# Next Step
|
||||||
- [ ] Manual test: visit `/`, see login form
|
|
||||||
- [ ] Manual test: enter wrong key, see error
|
|
||||||
- [ ] Manual test: enter correct signing key, see generator form
|
|
||||||
- [ ] Manual test: generate encrypted URL, verify it works
|
|
||||||
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
|
|
||||||
- [ ] Manual test: logout, verify redirected to login
|
|
||||||
|
|
||||||
### Cache Management
|
P2: security: per-IP rate limiting on the image routes
|
||||||
- [ ] Implement cache size management/eviction (prevent disk from filling up)
|
|
||||||
|
|
||||||
### Configuration
|
# Completed Steps
|
||||||
- [ ] Validate configuration on startup (fail fast on bad config)
|
|
||||||
|
|
||||||
## P1: Important for Production
|
- 2026-10-05 the format `auto` (closes #88): a format in the `/v1/image/` path,
|
||||||
|
an encrypted URL's token and the generator page's format choice, chosen for
|
||||||
|
each request from `Accept` once the signature or token is checked: AVIF when
|
||||||
|
the header names `image/avif`, else WebP when it names `image/webp`, else JPEG
|
||||||
|
when the first of `image/jpeg`, `image/*` and `*/*` that it names allows it,
|
||||||
|
or when it names nothing; `q=0` refuses a format. AVIF and WebP must be named,
|
||||||
|
as clients that cannot show them send the wildcards too. A header that allows
|
||||||
|
none of the three answers 406, one that does not parse 400. The signature and
|
||||||
|
the token cover `auto` itself; the cache key and `ETag` use the format chosen.
|
||||||
|
Answers from the point the format is chosen carry `Vary: Accept`, next to the
|
||||||
|
CORS `Vary: Origin`; fixed-format answers do not.
|
||||||
|
- 2026-10-05 the markdown is formatted with prettier (closes #100): `script/fmt`
|
||||||
|
and `script/fmt-check` run prettier 3.8.1, pinned in `package.json` and
|
||||||
|
`yarn.lock`, on `**/*.md` after `gofmt`, with four-space tabs and
|
||||||
|
`proseWrap: always` as `.prettierrc` says; `.prettierignore` keeps it off
|
||||||
|
`REPO_POLICIES.md`, the copy from `sneak/prompts`, and `vendor/`. Plain
|
||||||
|
`script/bootstrap` installs Node and Yarn as the one in `sneak/prompts` does
|
||||||
|
and then prettier; `script/bootstrap --cgo` does not, as the `Dockerfile`
|
||||||
|
stages that run it format nothing. The HTML templates stay unformatted:
|
||||||
|
prettier cannot parse a Go template action inside a tag. The markdown was
|
||||||
|
reflowed in a commit of its own.
|
||||||
|
- 2026-10-05 lint and tests run as the `lint` and `test` phases of the
|
||||||
|
`Dockerfile`, built with `--no-cache` (closes #202): `script/check`,
|
||||||
|
`script/cibuild`, `script/docker`, `script/lint`, `script/test`,
|
||||||
|
`script/setup` and `script/install-precommit` are now the copies from
|
||||||
|
`sneak/prompts` `main`, unchanged. The `lint` phase runs golangci-lint from
|
||||||
|
the image `REPO_POLICIES.md` names, with `libvips-dev` from `apt-get`; the
|
||||||
|
`test` phase runs the tests with a 90-second timeout; the build stage depends
|
||||||
|
on both. `Dockerfile.lint` and the `CHECK_EPOCH` build argument are gone, the
|
||||||
|
formatting check runs on the host, and `make docker-versioned` and
|
||||||
|
`make docker-test` call the scripts. `script/bootstrap`, `script/fmt`,
|
||||||
|
`script/fmt-check`, `script/precommit` and `script/projectname` stay pixa's
|
||||||
|
own. `script/bootstrap` installs git, make and Go, refreshing apt's package
|
||||||
|
lists before its first apt install; with `--cgo`, which only the `test` phase
|
||||||
|
and the build stage pass, it also installs the C compiler and the libvips and
|
||||||
|
libheif libraries. The stage that compiles still takes the version from
|
||||||
|
`git describe` when no `VERSION` is given, per
|
||||||
|
https://git.eeqj.de/sneak/pixa/issues/166, so the copied scripts' comment that
|
||||||
|
`.dockerignore` leaves out `.git` does not hold for pixa.
|
||||||
|
- 2026-10-04 load test (closes #81): `script/loadtest [duration [clients]]`
|
||||||
|
(`make loadtest`, defaults `10s` and `4`), a benchmark that `script/check`
|
||||||
|
does not run, measures three scenarios, each against a new pixad container and
|
||||||
|
a new upstream host, `cmd/loadtest-origin`: `hit` (one cached image), `miss`
|
||||||
|
(a new source image every request) and `herd` (each new source image asked for
|
||||||
|
by all clients at once). For each it prints vegeta's report (requests per
|
||||||
|
second, latency percentiles, status codes), pixad's peak resident memory and
|
||||||
|
the requests that reached the origin. `README.md` says how to run it and read
|
||||||
|
it, and keeps 1-5k r/s as a target not yet measured. First measurement, with
|
||||||
|
the defaults on a shared 48-CPU machine with other work running: a baseline
|
||||||
|
for later changes, not a test of the target. `hit` 1413 r/s, p50 0.7 ms, p95
|
||||||
|
8.7 ms, p99 44 ms, peak 53 MiB (4 clients that each wait for their answer, so
|
||||||
|
not pixad's limit); `miss` 70 r/s, p50 52 ms, p95 91 ms, p99 122 ms, peak 100
|
||||||
|
MiB, one fetch per request; `herd` 74 r/s, p50 52 ms, p95 69 ms, p99 111 ms,
|
||||||
|
peak 60 MiB, 188 fetches for 749 requests.
|
||||||
|
- 2026-10-04 the CI checkout fetches the tags (closes #208): the checkout step
|
||||||
|
in `.gitea/workflows/check.yml` sets `fetch-depth: 0`, as `REPO_POLICIES.md`
|
||||||
|
asks of a repo that takes its version from the tags, so a CI build of a tagged
|
||||||
|
commit stamps the tag from `git describe` instead of a bare commit.
|
||||||
|
- 2026-10-04 `config.yml` stays out of git and the Docker build context (closes
|
||||||
|
#212): `.gitignore` now ignores `config.yml`, the config file Getting Started
|
||||||
|
creates with the signing key, and `.dockerignore` leaves it out in every
|
||||||
|
directory and in any letter case, as it already did `config.yaml` and
|
||||||
|
`config.dev.yml`.
|
||||||
|
- 2026-10-04 local config files stay out of the Docker build context (closes
|
||||||
|
#211): `.dockerignore` now leaves out `config.yaml` and `config.dev.yml` in
|
||||||
|
every directory and in any letter case, the local config files `.gitignore`
|
||||||
|
keeps out of git because they can hold the signing key.
|
||||||
|
`configs/config.example.yml` is still sent. `config.yml`, which Getting
|
||||||
|
Started creates, is in neither file:
|
||||||
|
https://git.eeqj.de/sneak/pixa/issues/212.
|
||||||
|
- 2026-10-04 `cmd/pixad/main.go` is one call into `internal/` (closes #206):
|
||||||
|
what it did (the command line and its `--config` flag, setting
|
||||||
|
`PIXA_CONFIG_PATH`, ignoring `SIGPIPE`, starting the fx app) is now `Run` in
|
||||||
|
`internal/app`, unchanged, and `main` calls it with `Version`, which the build
|
||||||
|
still sets through `-X main.Version`. That code had no tests to move.
|
||||||
|
- 2026-10-04 `.gitignore` ignores `.claude/` (closes #204): the entry and its
|
||||||
|
comment are copied from the canonical `.gitignore` in `sneak/prompts`,
|
||||||
|
unanchored so it matches at every depth. `.dockerignore` already has
|
||||||
|
`.claude`.
|
||||||
|
- 2026-10-04 `.dockerignore` keeps secrets out at every depth (closes #205): the
|
||||||
|
file is now the standard one from `sneak/prompts`, whose patterns match in
|
||||||
|
every directory and, for environment files and private keys, in any letter
|
||||||
|
case, so a nested `.env` or `server.key` no longer reaches the build context.
|
||||||
|
pixa still sends `.git` without `.git/config` in place of the standard file's
|
||||||
|
`.git` line, and still leaves out `.gitignore`, `/bin` and `/data`.
|
||||||
|
- 2026-10-04 `REPO_POLICIES.md` matches the canonical copy again (closes #196):
|
||||||
|
it is replaced, unchanged, by `prompts/REPO_POLICIES.md` from `sneak/prompts`
|
||||||
|
`main`. The rules it adds that pixa's tree breaks are filed:
|
||||||
|
https://git.eeqj.de/sneak/pixa/issues/202 (lint and tests as `Dockerfile`
|
||||||
|
phases built with `--no-cache`), https://git.eeqj.de/sneak/pixa/issues/203
|
||||||
|
(the workflow's `script/docker-smoke` step),
|
||||||
|
https://git.eeqj.de/sneak/pixa/issues/204 (`.claude/` in `.gitignore`),
|
||||||
|
https://git.eeqj.de/sneak/pixa/issues/205 (`.dockerignore` patterns at every
|
||||||
|
depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin `cmd/pixad/main.go`)
|
||||||
|
and https://git.eeqj.de/sneak/pixa/issues/208 (`fetch-depth: 0` on the CI
|
||||||
|
checkout, so the build sees the tags). Its rule that no build stage runs
|
||||||
|
`git describe` is not followed: pixa takes the version from the `.git` in the
|
||||||
|
build context, per https://git.eeqj.de/sneak/pixa/issues/166, as the copy on
|
||||||
|
`sneak/prompts` `next` already says.
|
||||||
|
- 2026-10-04 an integration test of the image proxy flow (closes #80):
|
||||||
|
`TestImageProxyFlow` in `internal/server` starts the database, handlers and
|
||||||
|
middleware from the constructors `pixad` uses, with a fresh state directory,
|
||||||
|
and replaces only the upstream origin with a local test server. For a resize
|
||||||
|
with a change to JPEG and for `orig`, the first request goes through the
|
||||||
|
router, the real fetcher, libvips, the disk cache and SQLite and answers 200
|
||||||
|
with the right content type and size and `X-Pixa-Cache: MISS`; the second
|
||||||
|
answers `HIT` with the same image and the upstream has had one request; the
|
||||||
|
source and the converted image are then in `cache/sources` and
|
||||||
|
`cache/variants`, with their rows in `source_content`, `source_metadata` and
|
||||||
|
`variant_content`. Two optional fields make this possible, which `pixad` does
|
||||||
|
not set and the config file and environment cannot:
|
||||||
|
`httpfetcher.Config.DialContext` connects in place of the dialer that refuses
|
||||||
|
internal addresses, the URL and redirect checks still running, and
|
||||||
|
`handlers.Params.Fetcher` replaces the fetcher the handlers build.
|
||||||
|
- 2026-10-04 a URL made on the generator page with a `ttl` is tested to expire
|
||||||
|
(closes #199): a new test in `internal/handlers` makes a URL on the generator
|
||||||
|
page with a `ttl` of one second, checks that `/v1/e/` serves it at once, waits
|
||||||
|
two seconds and checks that it then answers 410. The test waits for real, as
|
||||||
|
pixa reads the clock directly when it makes and checks a URL; it waits two
|
||||||
|
seconds because the time a URL expires is kept in whole seconds. Test only.
|
||||||
|
- 2026-10-04 referer blocklist (closes #90): `referer_blocklist`
|
||||||
|
(`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for
|
||||||
|
`allowlist_hosts` with the same matcher; an entry of either list that is
|
||||||
|
neither a host name (letters, digits, hyphens, underscores and dots, with at
|
||||||
|
most one leading dot) nor an IP address, such as one with a port or a `*.`
|
||||||
|
wildcard, aborts startup naming the setting and the entry. Both image routes
|
||||||
|
refuse a request whose `Referer` names a listed host with 403 and a JSON error
|
||||||
|
before the signature, the cache and the upstream fetch, so it fetches nothing
|
||||||
|
and is refused whether or not the image is cached. A request with no
|
||||||
|
`Referer`, or one that does not parse as a URL with a host, is served, so the
|
||||||
|
list is easily got around; `README.md` and `configs/config.example.yml` say
|
||||||
|
so. It does not apply to the login and generator pages.
|
||||||
|
- 2026-10-04 fewer files in the repository root (closes #97):
|
||||||
|
`config.example.yml` moved unchanged to `configs/config.example.yml`, and
|
||||||
|
`README.md`, the comments in `internal/config/config.go` and the startup error
|
||||||
|
for the placeholder signing key name the new path; `scripts/manual-test.sh`
|
||||||
|
and its directory are deleted, as the handler tests in `internal/handlers`
|
||||||
|
cover every check it made except two: fetching a real image from the internet,
|
||||||
|
and a URL made on the generator page with a `ttl` answering 410 once the `ttl`
|
||||||
|
has passed (https://git.eeqj.de/sneak/pixa/issues/199); `CONVENTIONS.md` is
|
||||||
|
deleted, as `REPO_POLICIES.md` links the canonical Go HTTP server conventions.
|
||||||
|
- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes
|
||||||
|
#198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write
|
||||||
|
that finds another in progress on another connection waits up to five seconds
|
||||||
|
for it, and the default `db_url` turns on WAL mode with
|
||||||
|
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
|
||||||
|
parameter the driver reads, so the database was never in WAL mode.
|
||||||
|
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` only
|
||||||
|
passes through a periodic pass (closes #189): it slept for three eviction
|
||||||
|
intervals before writing its file, and a startup pass still running then could
|
||||||
|
adopt the file itself. It now holds the test database's only connection until
|
||||||
|
the startup pass waits for it after walking the empty variant directory,
|
||||||
|
writes the file and lets the connection go, as
|
||||||
|
`TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation
|
||||||
|
pass can adopt the file. Test only.
|
||||||
|
- 2026-10-04 logging in, logging out, the URL generator and `/v1/e/` have
|
||||||
|
handler tests (closes #77): new tests in `internal/handlers`, with no network,
|
||||||
|
check that `GET /` without a login session shows the login form; a wrong key
|
||||||
|
shows it again with an error and sets no session cookie; the right key answers
|
||||||
|
303 to `/` with a session cookie marked `Secure`, `HttpOnly` and
|
||||||
|
`SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout`
|
||||||
|
answers 303 to `/` with an empty session cookie sent with `Max-Age=0`;
|
||||||
|
`POST /generate` without a login session answers 303 to `/`; `/v1/e/` serves
|
||||||
|
the image for a valid token, answers 410 for an expired one and 400 for one
|
||||||
|
with a character changed, cut short or made with another signing key; and a
|
||||||
|
URL made on the generator page is served by `/v1/e/`. No code changes.
|
||||||
|
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
|
||||||
|
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
|
||||||
|
branches that each add an entry at the top of Completed Steps merge without a
|
||||||
|
conflict and keep both entries. Git now never reports a conflict in `TODO.md`:
|
||||||
|
a real one keeps both versions of the lines, and two entries that share an
|
||||||
|
identical line can end up one inside the other, which a rebase can do to an
|
||||||
|
entry already on `next`. The Workflow above says to read the merged entries
|
||||||
|
after every merge or rebase.
|
||||||
|
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
|
||||||
|
(closes #184): for an omitted key, the cache works out the limit when it
|
||||||
|
opens, after the database is open, as 75% of the sum of the free space on the
|
||||||
|
filesystem containing `<state_dir>/cache/` and what the cache already holds by
|
||||||
|
its own size accounting, at least 500 MiB, so a cache filled to its limit
|
||||||
|
keeps that limit across a restart. The computation and its tests moved from
|
||||||
|
`internal/config` to `internal/imgcache`; the config only records whether the
|
||||||
|
key was set.
|
||||||
|
- 2026-10-04 `TestEvictionRunsOnPeriodicSchedule` no longer races the evictor
|
||||||
|
(closes #183): it wrote each variant file and then inserted its accounting row
|
||||||
|
by hand, and a reconciliation pass between the two adopted the file first, so
|
||||||
|
the insert failed. It now writes the files only, while holding the test
|
||||||
|
database's only connection so the evictor's startup pass waits after walking
|
||||||
|
the empty variant directory; a periodic reconciliation pass then adopts the
|
||||||
|
files and the eviction pass after it evicts them. No other test in
|
||||||
|
`internal/imgcache` inserts a row by hand after starting the evictor. Test
|
||||||
|
only.
|
||||||
|
- 2026-10-04 a config file pixa cannot read aborts startup (closes #176): of the
|
||||||
|
places pixa looks for its config file on its own, only one where the file does
|
||||||
|
not exist is passed over; any other error, such as a directory on the path
|
||||||
|
that pixa may not enter, aborts startup naming the file, as a file that does
|
||||||
|
not parse already did.
|
||||||
|
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
|
||||||
|
the deprecated `gomodguard` is switched off, so lint runs print no deprecation
|
||||||
|
warning; its successor `gomodguard_v2` runs with the shared module block list,
|
||||||
|
and `depguard` keeps `net/http/httptest` out of files that are not tests. The
|
||||||
|
tree needed no code changes.
|
||||||
|
- 2026-10-04 the Content-Security-Policy allows no inline script or style
|
||||||
|
(closes #125): `script-src` and `style-src` are `'self'` only. The generator
|
||||||
|
page's two inline `onclick` handlers moved into
|
||||||
|
`internal/static/generator.js`, attached with `addEventListener`; the bundled
|
||||||
|
Tailwind script, which built styles in the browser, is replaced by a small
|
||||||
|
hand-written `internal/static/style.css` with only the rules the login and
|
||||||
|
generator pages use, the templates carrying a few plain class names in place
|
||||||
|
of Tailwind's. No build step. The pages keep their layout, not every pixel of
|
||||||
|
it.
|
||||||
|
- 2026-10-04 deployment guide and example Caddy config (closes #89):
|
||||||
|
"Deployment" in `README.md` says what the reverse proxy in front of pixa must
|
||||||
|
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
|
||||||
|
`X-Forwarded-For`, with `trusted_proxies` to match; wait at least
|
||||||
|
`downstream_timeout`; optionally refuse `/metrics`) and what pixa does itself,
|
||||||
|
that the state directory needs a persistent volume and what `cache_max_bytes`
|
||||||
|
counts, the health check for a load balancer, what a stop does and its exit
|
||||||
|
codes, and what running outside Docker needs; `configs/Caddyfile` is the
|
||||||
|
example, checked with `caddy validate`.
|
||||||
|
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and metrics
|
||||||
|
recording have tests (closes #79): `MetricsAuth` on its own answers 401 with a
|
||||||
|
challenge without credentials or with a wrong username or password and lets
|
||||||
|
the configured ones through; a preflight request gets `*` for any origin when
|
||||||
|
`access_control_allow_origin` is `*` and no `Access-Control-Allow-Origin` from
|
||||||
|
another origin than the configured one; a `POST /` carrying the signing key
|
||||||
|
leaves no trace of it in the request log line, and the login handler's own log
|
||||||
|
lines leave out the submitted key; the metrics middleware on its own records a
|
||||||
|
request it served, and the router records nothing while no metrics username is
|
||||||
|
set. Not tested: that the router puts the basic auth in front of `/metrics`
|
||||||
|
and records requests when a metrics username is set. Only one test per package
|
||||||
|
can set up `/metrics`, and in `internal/server` that is
|
||||||
|
`TestMaintenanceModeKeepsOtherRoutes`, which needs the owner's approval to
|
||||||
|
change; #180 holds it. Tests only; the basic auth library already compares the
|
||||||
|
password in constant time.
|
||||||
|
- 2026-10-04 the image route's signature check and error answers are tested
|
||||||
|
(closes #76): new tests in `internal/handlers`, with no network, check the
|
||||||
|
status and JSON error body for a missing, wrong, unpadded, upper-case or
|
||||||
|
expired signature on a host not on the allowlist, or a valid one sent for its
|
||||||
|
parent domain, a sibling host, a subdomain or the host with another domain
|
||||||
|
appended (401), an unparseable path (400), `localhost` as the upstream host
|
||||||
|
(403) and an upstream error (502); that an allowlisted host is served without
|
||||||
|
a signature, another host only with a valid one; and the answers of
|
||||||
|
`/robots.txt` and the health check. No code changes.
|
||||||
|
- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates
|
||||||
|
(closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each
|
||||||
|
request an ID, its own `X-Request-ID` when that is at most 64 letters, digits,
|
||||||
|
`-`, `_` or `.` and a random one otherwise, stores it where chi's did and
|
||||||
|
sends it back as `X-Request-ID` on every response; the upstream fetch sends
|
||||||
|
that ID, and the "upstream fetched", "image converted" and "image served" log
|
||||||
|
lines carry it as `request_id`, a fetch shared by several requests carrying
|
||||||
|
the first request's; `/v1/e/` sets `ETag`, answers a matching `If-None-Match`
|
||||||
|
with 304 and is routed for `HEAD`, the `ETag` and 304 code being
|
||||||
|
`notModified`, which `/v1/image/` calls too; its token checks moved unchanged
|
||||||
|
into `parseImageEncRequest` to keep `HandleImageEnc` within the line limit; no
|
||||||
|
`Vary` is added, as no response depends on a request header except the image
|
||||||
|
routes' CORS headers, for which `go-chi/cors` already sends `Vary: Origin`;
|
||||||
|
`Vary: Accept` is left to #88.
|
||||||
|
- 2026-10-04 routes, encrypted URLs and config file documented (closes #75):
|
||||||
|
"Routes" in `README.md` lists every route with its method, purpose, what it
|
||||||
|
needs and the status codes it answers with, and says `q` and `fit` are part of
|
||||||
|
what is cached; "Encrypted URLs" covers logging in, making one on the
|
||||||
|
generator page, how long it lasts and the 410 once it has expired;
|
||||||
|
"Configuration" gives the order in which pixa looks for its config file;
|
||||||
|
`config.example.yml` lists `db_url` and `env` and gives every key's default;
|
||||||
|
`scripts/manual-test.sh` is left to #97.
|
||||||
|
- 2026-10-04 shutdown stops cache eviction in progress (closes #102):
|
||||||
|
`StartEviction` runs the eviction goroutine with its own context, which
|
||||||
|
`StopEviction` cancels, so a pass in progress stops at its next database call,
|
||||||
|
file, row or eviction candidate instead of running to completion, and no pass
|
||||||
|
starts after it, so a stop logs at most one warning; `StopEviction` takes a
|
||||||
|
context and, when that context ends before the goroutine exits, stops waiting
|
||||||
|
and returns its error; the handlers' stop hook passes fx's stop context, so an
|
||||||
|
eviction still running when fx's stop deadline ends fails the stop and makes
|
||||||
|
the exit code 1.
|
||||||
|
- 2026-10-04 dead code in `internal/imgcache` is gone (closes #73): `Purge`,
|
||||||
|
which only returned an error and which nothing called, is no longer part of
|
||||||
|
the `ImageCache` interface or `Service`; the `SignatureValidator`, `Allowlist`
|
||||||
|
and `Storage` interfaces, which nothing implemented or used, are deleted.
|
||||||
|
Nothing else changes.
|
||||||
|
- 2026-10-04 upstream host semaphores and variant `.meta` files no longer
|
||||||
|
outlive their use (closes #87): the fetcher counts the fetches holding or
|
||||||
|
waiting for a slot of each upstream host's semaphore and removes the host's
|
||||||
|
semaphore once none is left, so fetches from many hosts no longer leave one
|
||||||
|
semaphore each until restart; `VariantStorage.Delete` removes the variant's
|
||||||
|
`.meta` file along with it, a missing `.meta` file not being an error, and
|
||||||
|
`DeleteWithMeta`, which eviction called for that, is gone.
|
||||||
|
- 2026-10-04 `README.md` matches the code (closes #74): "Storage" names the
|
||||||
|
cache directories pixa uses (`cache/sources`, `cache/metadata`,
|
||||||
|
`cache/variants`) and how files are named in each, and the comments in
|
||||||
|
`001_schema.sql` name the same paths; the routes and the signature section
|
||||||
|
list the same output formats, `jpg` and `original` included; the TLS sentence
|
||||||
|
names `allow_http` as its exception; "Metrics" says only generic HTTP and Go
|
||||||
|
runtime metrics exist, measured and served only when the metrics username and
|
||||||
|
password are set.
|
||||||
|
- 2026-10-03 shutdown sets the exit code and waits for image processing (closes
|
||||||
|
#86): fx alone handles SIGINT and SIGTERM, and the server's own signal handler
|
||||||
|
is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's code: 0 for a
|
||||||
|
signal, 1 when the HTTP server cannot listen or the app fails to start or to
|
||||||
|
stop; the server's stop hook, which fx waits for, stops the HTTP server, waits
|
||||||
|
for the images still being processed, both within 5 seconds, then flushes
|
||||||
|
Sentry; images still being processed after that are logged with their count
|
||||||
|
and make the exit code 1; a Sentry DSN that cannot be used fails startup, so
|
||||||
|
the stop hooks of what had already started run, instead of exiting the process
|
||||||
|
from a goroutine; the eviction loop is left to #102.
|
||||||
|
- 2026-10-03 every `script/cibuild` and `script/docker` run executes the checks
|
||||||
|
(closes #101): the `Dockerfile` declares `CHECK_EPOCH` above `make fmt-check`
|
||||||
|
and `make lint` in the lint stage and above `make test` in the build stage,
|
||||||
|
and each of those steps names it in its command; both scripts pass a new value
|
||||||
|
on every run, so Docker runs the checks instead of reusing cached results,
|
||||||
|
while the `script/bootstrap` steps stay cached; a plain `docker build .` still
|
||||||
|
works, leaves it empty, and reuses the check steps only for an identical build
|
||||||
|
context; the `script/cibuild` comment and `README.md` no longer say that any
|
||||||
|
successful build implies a green repo.
|
||||||
|
- 2026-09-29 share concurrent misses (closes #65): requests that miss the same
|
||||||
|
variant at once (the same cache key, so quality and fit included) share one
|
||||||
|
upstream fetch or cached source read and one transcode through
|
||||||
|
`golang.org/x/sync/singleflight`; the first request's processing ignores its
|
||||||
|
cancellation but keeps its deadline, and the others wait for its image or
|
||||||
|
error holding no upstream connection or processing slot, and stop waiting when
|
||||||
|
their own context ends; the request doing the processing waits for it even
|
||||||
|
then, up to its deadline; a request whose context has already ended starts
|
||||||
|
nothing; each request counts one miss, and the processing counts its fetch and
|
||||||
|
transcode once; a panic while processing is reported to Sentry when
|
||||||
|
`sentry_dsn` is set and becomes an error for every waiting request instead of
|
||||||
|
stopping pixad; documented in `README.md`.
|
||||||
|
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
|
||||||
|
middleware, with the `access_control_allow_origin` origin, moved from the
|
||||||
|
router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
|
||||||
|
still answers a preflight `OPTIONS` request; the login and URL generator
|
||||||
|
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
|
||||||
|
documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not `dev`
|
||||||
|
(closes #166): `.dockerignore` lets `.git` into the build context, without
|
||||||
|
`.git/config`; with no `VERSION` build argument the `Dockerfile` takes the
|
||||||
|
version from `git describe --tags --always`, and fails the build if the
|
||||||
|
context carries `.git` and no version comes out; `ARG VERSION` has no default;
|
||||||
|
pixad logs its version, with its name and architecture, as its first log line
|
||||||
|
at startup.
|
||||||
|
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes #159):
|
||||||
|
`deploy/docker-entrypoint.sh` creates the directory if it is missing, gives
|
||||||
|
the directory and everything in it to `pixad` when the directory or one of its
|
||||||
|
top-level entries belongs to another user or group, sets its mode to `750`,
|
||||||
|
then runs the server as `pixad`; data left by an earlier run under another uid
|
||||||
|
is taken over this way; "Running under upaas" in `README.md` no longer tells
|
||||||
|
the operator to create or chown the host directory.
|
||||||
|
- 2026-09-29 variant content types kept in memory (closes #70):
|
||||||
|
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
|
||||||
|
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
|
||||||
|
`GetVariant` after it reads a `.meta` file, where a type `StoreVariant` added
|
||||||
|
meanwhile is kept over the one read, and never with the
|
||||||
|
`application/octet-stream` served for a variant without one; for a variant it
|
||||||
|
holds, `GetVariant` skips the `.meta` read, still opening the variant file and
|
||||||
|
taking the size from it; eviction removes the entry before deleting the files,
|
||||||
|
and `GetVariant` removes it when the file will not open; the cap is a
|
||||||
|
constant, not a setting; the unused `variantMeta` type is gone; `README.md`
|
||||||
|
describes it.
|
||||||
|
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
||||||
|
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
||||||
|
`Retry-After` header and the JSON error body, from one middleware in
|
||||||
|
`internal/server/routes.go`; the health check stays 200 and reports
|
||||||
|
`maintenance_mode`, as the image's Docker `HEALTHCHECK` requests it and upaas
|
||||||
|
marks a deploy failed when its container is unhealthy; the login and URL
|
||||||
|
generator pages and `/metrics` keep working; documented in `README.md` and
|
||||||
|
`config.example.yml`.
|
||||||
|
- 2026-09-29 bound concurrent image processing and upstream fetches (closes
|
||||||
|
#64): `max_concurrent_processing` (default the number of CPUs pixa can use)
|
||||||
|
limits the images decoded and encoded at once, and `upstream_connections`
|
||||||
|
(default 64) the connections to all upstream hosts together, on top of
|
||||||
|
`upstream_connections_per_host`; a fetch holds its connection until its image
|
||||||
|
has been processed, and a request whose source is cached reads it only once it
|
||||||
|
has a processing slot; a request that finds either limit reached waits up to
|
||||||
|
10 seconds for a free one, then gets 503 `server busy, try again later`;
|
||||||
|
libvips runs one worker thread per image with its operation cache off;
|
||||||
|
documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
|
||||||
|
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
|
||||||
|
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
|
||||||
|
`apk add` lines, so the build dependencies are listed in one place;
|
||||||
|
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
|
||||||
|
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
|
||||||
|
`ARG VERSION` sits just above the build, so a new version reruns neither
|
||||||
|
`script/bootstrap` nor the tests.
|
||||||
|
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
|
||||||
|
migration files moved, contents unchanged, from `internal/database/schema/` to
|
||||||
|
`internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
|
||||||
|
`internal/db/migrations` package embeds them and `internal/database` reads
|
||||||
|
them through its `FS()`; the `internal/database` package itself stays; the
|
||||||
|
version still comes from the filename prefix, so a database that has recorded
|
||||||
|
versions 0 and 1 runs neither again.
|
||||||
|
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
||||||
|
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
||||||
|
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
||||||
|
requests that come through the proxy, which the request log shows as
|
||||||
|
`remoteIP` while it is not trusted; for a proxy on the Docker host that
|
||||||
|
connects over `127.0.0.1` that is the Docker network's gateway, not the
|
||||||
|
proxy's own address; the signature section says `sig` is base64url with the
|
||||||
|
`=` padding kept, and gives the example's `sig` for a stated signing key.
|
||||||
|
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
|
||||||
|
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
|
||||||
|
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad` is
|
||||||
|
not owned on the host by a person's login account; the first-run step of
|
||||||
|
"Running under upaas" in `README.md` names the uid and gid.
|
||||||
|
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
|
||||||
|
routes build `Cache-Control` from the request's `Expires`, which an encrypted
|
||||||
|
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
|
||||||
|
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
|
||||||
|
that is sooner, never negative; an allowlisted host's URL that has an `exp`
|
||||||
|
follows it too; `immutable` stays, as freshness now ends at the expiry;
|
||||||
|
documented in `README.md`.
|
||||||
|
- 2026-09-28 add the four settings `README.md` documented but pixa did not have,
|
||||||
|
which aborted startup as unknown keys (closes #61):
|
||||||
|
`access_control_allow_origin` (default `*`, the CORS origin),
|
||||||
|
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
|
||||||
|
(default 50 MiB) and `downstream_timeout` (default `60s`, both the server's
|
||||||
|
write timeout and the per-request timeout); each has a `PIXA_` variable;
|
||||||
|
durations are positive Go duration strings, the size a whole number of bytes
|
||||||
|
up to 1 GiB, the origin `*` or one `http` or `https` origin as `README.md`
|
||||||
|
describes it; an invalid value aborts startup naming the key and the value;
|
||||||
|
documented in `config.example.yml` and `README.md`.
|
||||||
|
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` counts
|
||||||
|
the cached source images and processed variants (`source_content` plus
|
||||||
|
`variant_content`) and takes their size from `Cache.UsageBytes`, instead of
|
||||||
|
reading `request_cache` and `output_content`, which nothing writes; those two
|
||||||
|
tables are left in the schema; a disabled disk cache reports no items and no
|
||||||
|
size. A hit is counted even when the request context has ended. A miss is
|
||||||
|
counted after it is served or fails, also when the request context has ended
|
||||||
|
by then, with the bytes it read from upstream, so `upstream_fetch_count` and
|
||||||
|
`upstream_fetch_bytes` move, including for an upstream body that fails partway
|
||||||
|
or a fetched source that then fails the magic byte check; `transform_count`
|
||||||
|
counts each image the image processor transcodes.
|
||||||
|
- 2026-09-28 strip metadata from processed images (closes #82): every output is
|
||||||
|
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
|
||||||
|
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
||||||
|
worked out) and, when it has an ICC profile, converted to sRGB; the `orig`
|
||||||
|
format is re-encoded and stripped like any other, as pixa never serves the
|
||||||
|
source bytes; there is no setting to keep metadata; documented in `README.md`.
|
||||||
|
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
|
||||||
|
attempts per minute per client address, and an attempt over the limit is
|
||||||
|
refused with 429 and a `Retry-After` header; the address is the one
|
||||||
|
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
|
||||||
|
counted by its /64, and an IPv4-mapped address as the IPv4 address it carries;
|
||||||
|
the limit is a `RateLimit` middleware in `internal/middleware` on
|
||||||
|
`github.com/go-chi/httprate`, which the image routes can reuse; the library
|
||||||
|
keeps counts for the current and the previous minute only; documented in
|
||||||
|
`README.md`.
|
||||||
|
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed cache
|
||||||
|
errors (closes #72): an `exp` in the URL that is not a whole number, an empty
|
||||||
|
`exp=` included, is a 400 naming `exp` and the value, instead of being ignored
|
||||||
|
and answered with 401 as if the URL had no `exp`; only an `exp` missing from
|
||||||
|
the URL is unchanged; `README.md` says so where it documents `exp`. A failed
|
||||||
|
variant `.meta` write, source metadata JSON write, `Stats` count query, stats
|
||||||
|
counter update, negative cache write or expired negative cache delete is now
|
||||||
|
logged at `warn` with the path or key and the error, and stays non-fatal.
|
||||||
|
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a `fit` in the
|
||||||
|
URL with an empty value (`fit=`) is a 400 naming `fit`, instead of being
|
||||||
|
served as `cover` and verified against a signature made for `cover`; only a
|
||||||
|
`fit` missing from the URL is still `cover`; any other value still goes
|
||||||
|
through the existing fit-mode check; `README.md` says so where it documents
|
||||||
|
`fit`.
|
||||||
|
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q` that is
|
||||||
|
not a whole number from 1 to 100, an empty `q` included, is a 400 naming `q`
|
||||||
|
and the value, instead of being served at the default 85; the route reads `q`
|
||||||
|
with the generator's quality check (`parseFormInt` with `minQuality` and
|
||||||
|
`maxQuality`); only a `q` missing from the URL is still 85; a query string
|
||||||
|
that cannot be decoded, such as `q=80%`, is a 400 showing it; any query
|
||||||
|
parameter given more than once (`q`, `fit`, `sig`, `exp` alike) is a 400
|
||||||
|
naming it, so none is read from its first value only; `README.md` states the
|
||||||
|
range and both query-string rules.
|
||||||
|
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes #133):
|
||||||
|
a variable whose name starts with `PIXA_` but is neither a setting's variable
|
||||||
|
nor `PIXA_CONFIG_PATH` aborts startup naming it, as an unknown config key
|
||||||
|
does, and `PIXA_PORT` is named with a pointer to `PORT`; the check runs after
|
||||||
|
the config file loads, so the variables the file's `env:` section sets are
|
||||||
|
checked too; documented in `README.md`.
|
||||||
|
- 2026-09-28 start on a fresh upaas volume (closes #129): the image starts as
|
||||||
|
root only to give `/var/lib/pixa` to `pixad` when `pixad` does not own it
|
||||||
|
(`deploy/docker-entrypoint.sh`), then runs the server as `pixad` through
|
||||||
|
`su-exec`, so a root-owned host directory bind-mounted there no longer stops
|
||||||
|
the container at startup; `README.md` gains a "Running under upaas" section.
|
||||||
|
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + `script/lint`
|
||||||
|
(closes #104): `make lint` calls `script/lint`, the only way the linter is
|
||||||
|
run; inside a container (both Dockerfiles set `container=docker`) it runs
|
||||||
|
`golangci-lint`, anywhere else it builds the hash-pinned `Dockerfile.lint`,
|
||||||
|
whose last step runs `script/lint` again; the `Dockerfile` lint stage runs
|
||||||
|
`make lint`; no host or nix-shell `golangci-lint` path remains
|
||||||
|
(`script/bootstrap` installs no linter); a per-run `CACHEBUST` build-arg keeps
|
||||||
|
the lint step from being served from cache, and a tmpfs mount on that step
|
||||||
|
keeps Go's and golangci-lint's caches out of its layer, so a run leaves no
|
||||||
|
large build cache behind; `golangci-lint config verify` stays out, as it
|
||||||
|
fetches its schema over an unpinned live HTTPS call
|
||||||
|
- 2026-09-28 every setting as an environment variable (closes #128, also covers
|
||||||
|
#99): each config key can be set by `PIXA_` plus the key in upper case (`.`
|
||||||
|
written as `_`), and the port by `PORT`; a variable present in the
|
||||||
|
environment, even empty, wins over the config file, which wins over the
|
||||||
|
default; the typed getters read the variable first, so every existing check
|
||||||
|
applies to it and a bad value aborts startup naming the variable; lists are
|
||||||
|
comma-separated, and an empty variable (or `""` in the file) is an empty list;
|
||||||
|
the Docker image no longer bakes in `config.docker.yml` or passes `--config`,
|
||||||
|
and its `HEALTHCHECK` probes `PORT` (default `8080`); the config file is
|
||||||
|
looked for under `/etc/pixa` and `~/.config/pixa` instead of the daemon name
|
||||||
|
`pixad`; documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed data
|
||||||
|
is now `host:path:query:width:height:format:expiration:quality:fit`, using
|
||||||
|
`85` and `cover` when the URL has no `q` or `fit`, so one signed URL can no
|
||||||
|
longer be replayed across other quality and fit values to create unauthorized
|
||||||
|
cache entries and transcodes; the known-answer vectors in
|
||||||
|
`internal/signature/golden_test.go` and the README signature specification
|
||||||
|
describe the new format.
|
||||||
|
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in the
|
||||||
|
runtime stage probing `/.well-known/healthcheck.json` with busybox `wget`;
|
||||||
|
`script/docker-smoke` (`make docker-smoke`) builds the image, starts it with a
|
||||||
|
throwaway `PIXA_SIGNING_KEY`, and passes only once Docker reports it healthy
|
||||||
|
within 30 seconds, removing the container on exit; the Gitea workflow runs it
|
||||||
|
after `script/cibuild`.
|
||||||
|
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
|
||||||
|
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
|
||||||
|
`net/netip` list parser as `blocked_networks` (an invalid entry aborts startup
|
||||||
|
naming the key and value; an omitted key defaults to the RFC 1918 private
|
||||||
|
ranges, an explicitly empty list trusts no one, and an explicit list replaces
|
||||||
|
the default); a new `internal/clientip` package resolves the client address by
|
||||||
|
honoring `X-Forwarded-For` only when the direct peer is a trusted proxy,
|
||||||
|
walking the chain right-to-left to the rightmost non-proxy entry, so a client
|
||||||
|
connecting directly cannot spoof its address; the resolved address is stored
|
||||||
|
in the request context by a new middleware and used by the request-logging
|
||||||
|
middleware and the login-attempt logs in place of the raw peer address;
|
||||||
|
documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-21 blocked networks configuration extending SSRF protection: a
|
||||||
|
`blocked_networks` config key taking a list of CIDRs (parsed with `net/netip`,
|
||||||
|
an invalid entry aborts startup naming the key and value), added to the
|
||||||
|
built-in blocklist rather than replacing it; the built-in ranges extended to
|
||||||
|
CGNAT `100.64.0.0/10`, IETF protocol assignments `192.0.0.0/24`, benchmark
|
||||||
|
`198.18.0.0/15`, and NAT64 `64:ff9b::/96` (IPv4-mapped forms covered);
|
||||||
|
enforcement stays in the dial-time re-resolution so the DNS-rebinding window
|
||||||
|
remains closed; documented in `README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL route and the
|
||||||
|
token generator (closes #62): `imgcache.ValidateDimension` alone holds the
|
||||||
|
`MaxDimension` bound and is used by the path parser, by the new
|
||||||
|
`ValidateImageRequest` (which also applies `ValidateFitMode`) and by the
|
||||||
|
generator; both the `/v1/image/` and `/v1/e/` routes call
|
||||||
|
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a 400
|
||||||
|
rather than an out-of-memory or a 500 from the processor; the URL generator
|
||||||
|
answers 400 naming the field for a `width` or `height` that is not a number or
|
||||||
|
fails the shared check, a `quality` that is not a number from 1 to 100, a
|
||||||
|
`ttl` that is not a number from 0 to the largest number of seconds the expiry
|
||||||
|
calculation can hold, or an unknown `fit`; an empty `quality` is 85 and an
|
||||||
|
empty `ttl` never expires; the form's width and height inputs stop at 8192
|
||||||
|
- 2026-09-21 http.Server hardening (closes #92): added `HTTPReadHeaderTimeout`
|
||||||
|
(10s, bounds the slowloris header dribble) and `HTTPIdleTimeout` (120s, bounds
|
||||||
|
keep-alive reuse) alongside the existing timeouts and wired them onto the
|
||||||
|
server; added a `LimitBody` middleware capping the two form POST bodies
|
||||||
|
(`POST /`, `POST /generate`) at `MaxFormBytes` (1 MiB) and returning 413,
|
||||||
|
applied ahead of the CSRF middleware so an oversized body is refused as 413
|
||||||
|
rather than being read as a missing CSRF token (403); left `WriteTimeout` at
|
||||||
|
60s unchanged
|
||||||
|
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical `.golangci.yml`
|
||||||
|
(v2 schema, `default: all` minus six disabled linters, `lll` 88, tests
|
||||||
|
included): bumped the pinned `golangci/golangci-lint:v2.12.2-alpine` image in
|
||||||
|
`Dockerfile` and the release-archive sha256 pins in `script/bootstrap`; fixed
|
||||||
|
the findings the stricter config surfaced (notably `paralleltest`, `wsl_v5`,
|
||||||
|
`goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`, `testpackage` —
|
||||||
|
white-box test files renamed to `*_internal_test.go`), including #55's code
|
||||||
|
absorbed after it merged, iterating the pinned linter to `0 issues.`; no
|
||||||
|
single finding total is substantiable, since golangci-lint's `uniq-by-line`
|
||||||
|
reveals new findings on a line as others there are fixed — the documented
|
||||||
|
re-measurements were 81 after the #53 merge and 149 after the #55 merge; three
|
||||||
|
behavior changes, so not a pure no-op: `Cache.StoreVariant` now takes a
|
||||||
|
`context.Context` (`noctx`), so a cancelled request skips its best-effort
|
||||||
|
accounting row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and
|
||||||
|
leaked `.tmp-*.json` on failure, now fixed with explicit removals; and the
|
||||||
|
`signing_key` validation error text gained `value too short: `; the eviction
|
||||||
|
loop's uncancellable context is deferred to #102 under a
|
||||||
|
`//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep the
|
||||||
|
snake_case JSON wire/disk formats unchanged; `make check` green
|
||||||
|
- 2026-08-07 implement cache size management and eviction (closes #51): new
|
||||||
|
`cache_max_bytes` config key validated by the startup framework (explicit
|
||||||
|
values used exactly with no floor, `0` disables the disk cache entirely,
|
||||||
|
omitted defaults to max(75% of free space on the filesystem containing
|
||||||
|
`<state_dir>/cache/`, 500 MiB), logged at startup); processed variants are now
|
||||||
|
tracked in the database (a new `variant_content` table and an LRU timestamp on
|
||||||
|
`source_content`) so total usage is two SUMs, never a directory scan on the
|
||||||
|
hot path; a background goroutine evicts globally least-recently-used entries
|
||||||
|
(variants and source blobs merged) to the limit, woken by a periodic ticker
|
||||||
|
and by write-pressure notifications from stores; a source blob and ALL of its
|
||||||
|
`source_metadata` references are deleted in one transaction before the file is
|
||||||
|
unlinked, so multi-referenced blobs are never removed while referenced and
|
||||||
|
rows never point at deleted files; a startup and periodic reconciliation pass
|
||||||
|
adopts untracked variant files, drops rows for missing files, removes
|
||||||
|
unreachable source blobs, and sweeps stale temp files
|
||||||
|
- 2026-08-07 validate configuration on startup, fail fast on bad config (closes
|
||||||
|
#52): a config value that is set but unparseable or invalid aborts startup
|
||||||
|
naming the key and value (defaults apply only to omitted keys), unknown config
|
||||||
|
keys abort startup, a malformed config file aborts instead of being skipped,
|
||||||
|
and `state_dir` is verified creatable and writable before the listener binds
|
||||||
|
- 2026-08-07 manual test pass of the auth and encrypted URL flows against a
|
||||||
|
locally built and running `pixad` (built from `main` at `6573b9d`, port 18099,
|
||||||
|
local throwaway config); all six checks passed, plus all nine tests in
|
||||||
|
`scripts/manual-test.sh` (closes #49):
|
||||||
|
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login` page with
|
||||||
|
`name="key"` password form
|
||||||
|
- [x] wrong key shows an error: POST `/` with `key=wrong-key` returned HTTP
|
||||||
|
200 login page containing "Invalid signing key"
|
||||||
|
- [x] correct signing key shows the generator form: POST `/` returned HTTP
|
||||||
|
303 to `/` with
|
||||||
|
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`; GET
|
||||||
|
`/` with that cookie rendered `Pixa - URL Generator` with the
|
||||||
|
`/generate` form and logout link
|
||||||
|
- [x] a generated encrypted URL serves the image: POST `/generate`
|
||||||
|
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned HTTP
|
||||||
|
200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of 61706
|
||||||
|
bytes
|
||||||
|
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched after 3 s
|
||||||
|
returned HTTP 410 Gone with
|
||||||
|
`{"error":"URL has expired","status":410,...}`
|
||||||
|
- [x] logout redirects back to login: GET `/logout` returned HTTP 303 to `/`
|
||||||
|
with `Set-Cookie: pixa_session=; Max-Age=0`; subsequent GET `/`
|
||||||
|
rendered the login form again
|
||||||
|
- 2026-08-07 fix the two remaining gosec findings (G124 in internal/session):
|
||||||
|
session cookies now always carry Secure/HttpOnly/SameSite=Strict on both the
|
||||||
|
set and clear paths; `make check` green (closes #47)
|
||||||
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
|
shims, README Entrypoints section
|
||||||
|
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
||||||
|
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
|
||||||
|
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
|
||||||
|
- 2026-03-20 enforce and document exact-match-only signature verification (#40)
|
||||||
|
- 2026-03-20 bound imageprocessor.Process input read to prevent unbounded memory
|
||||||
|
use (#37); consolidate appname into an internal/globals constant (#34)
|
||||||
|
- 2026-03-18 parse version prefix from migration filenames (#33)
|
||||||
|
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
|
||||||
|
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for faster CI
|
||||||
|
(#23)
|
||||||
|
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
|
||||||
|
golangci-lint and gosec fixes of that date (#14); arm64 Docker build fix (#16)
|
||||||
|
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 image
|
||||||
|
processing items, now done)
|
||||||
|
|
||||||
### Security
|
# Future Steps
|
||||||
- [ ] Implement blocked networks configuration (extend SSRF protection)
|
|
||||||
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
|
|
||||||
|
|
||||||
### Image Processing
|
- P2: security
|
||||||
- [ ] Implement EXIF/metadata stripping (privacy)
|
- per-origin rate limiting
|
||||||
|
- P2: HTTP response handling
|
||||||
## P2: Nice to Have
|
- Last-Modified headers
|
||||||
|
- P2: configuration
|
||||||
### Security
|
- YAML config file support
|
||||||
- [ ] Implement referer blacklist
|
- P2: operational
|
||||||
- [ ] Add rate limiting per-IP
|
- optional Sentry error reporting
|
||||||
- [ ] Add rate limiting per-origin
|
- comprehensive request logging
|
||||||
|
- Prometheus performance metrics
|
||||||
### HTTP Response Handling
|
- measure the 1k to 5k req/s target with `script/loadtest` on a machine not
|
||||||
- [ ] Implement Last-Modified headers
|
shared with other work
|
||||||
- [ ] Implement Vary header for content negotiation
|
|
||||||
- [ ] Implement X-Request-ID propagation
|
|
||||||
|
|
||||||
### Additional Endpoints
|
|
||||||
- [ ] Implement auto-format selection (format=auto based on Accept header)
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
- [ ] Add all configuration options from README
|
|
||||||
- [ ] Implement environment variable overrides
|
|
||||||
- [ ] Implement YAML config file support
|
|
||||||
|
|
||||||
### Operational
|
|
||||||
- [ ] Implement Sentry error reporting (optional)
|
|
||||||
- [ ] Add comprehensive request logging
|
|
||||||
- [ ] Add performance metrics (Prometheus)
|
|
||||||
- [ ] Write integration tests for image proxy flow
|
|
||||||
- [ ] Write load tests to verify 1-5k req/s target
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [ ] Document configuration options
|
|
||||||
- [ ] Document API endpoints
|
|
||||||
- [ ] Document deployment guide
|
|
||||||
- [ ] Add example nginx/caddy reverse proxy config
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+3
-57
@@ -1,64 +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"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
var Version string //nolint:gochecknoglobals // set by ldflags
|
||||||
"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 (
|
|
||||||
Appname = "pixad" //nolint:gochecknoglobals // set by ldflags
|
|
||||||
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")
|
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(_ *cobra.Command, _ []string) {
|
|
||||||
globals.Appname = Appname
|
|
||||||
globals.Version = Version
|
|
||||||
|
|
||||||
// Set config path in environment if specified via flag
|
|
||||||
if configPath != "" {
|
|
||||||
_ = os.Setenv("PIXA_CONFIG_PATH", configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
fx.New(
|
|
||||||
fx.Provide(
|
|
||||||
config.New,
|
|
||||||
database.New,
|
|
||||||
globals.New,
|
|
||||||
handlers.New,
|
|
||||||
logger.New,
|
|
||||||
server.New,
|
|
||||||
middleware.New,
|
|
||||||
healthcheck.New,
|
|
||||||
),
|
|
||||||
fx.Invoke(func(*server.Server) {}),
|
|
||||||
).Run()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Pixa Example Configuration
|
|
||||||
|
|
||||||
# Server settings
|
|
||||||
port: 8080
|
|
||||||
debug: false
|
|
||||||
maintenance_mode: false
|
|
||||||
|
|
||||||
# Data directory for SQLite database and cache files
|
|
||||||
state_dir: ./data
|
|
||||||
|
|
||||||
# Image proxy settings
|
|
||||||
# HMAC signing key for URL signatures (leave empty to require whitelist for all requests)
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
|
||||||
|
|
||||||
# Hosts that don't require signatures
|
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
|
||||||
whitelist_hosts:
|
|
||||||
- s3.sneak.cloud
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- github.com
|
|
||||||
- user-images.githubusercontent.com
|
|
||||||
|
|
||||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
|
||||||
allow_http: false
|
|
||||||
|
|
||||||
# Maximum concurrent connections per upstream host (default: 20)
|
|
||||||
upstream_connections_per_host: 20
|
|
||||||
|
|
||||||
# Sentry error reporting (optional)
|
|
||||||
sentry_dsn: ""
|
|
||||||
|
|
||||||
# Metrics endpoint authentication (optional)
|
|
||||||
# metrics:
|
|
||||||
# username: "admin"
|
|
||||||
# password: "secret"
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Pixa Example Configuration
|
||||||
|
#
|
||||||
|
# Every key can also be set by an environment variable, which wins over
|
||||||
|
# this file: PIXA_ plus the key in upper case, with "." written as "_"
|
||||||
|
# (state_dir is PIXA_STATE_DIR, metrics.username is
|
||||||
|
# PIXA_METRICS_USERNAME). The one exception is port, which is set by
|
||||||
|
# PORT. In a variable, a list is comma-separated. A variable named in
|
||||||
|
# this file's env: section is set while the file loads, so it overrides
|
||||||
|
# both the environment the process was started with and this file's own
|
||||||
|
# key.
|
||||||
|
#
|
||||||
|
# Durations are Go duration strings such as 30s or 2m and must be
|
||||||
|
# positive; a bare number has no unit and aborts startup. Sizes are a
|
||||||
|
# whole number of bytes.
|
||||||
|
#
|
||||||
|
# A key left out takes the default its comment gives.
|
||||||
|
|
||||||
|
# Port to listen on (default: 8080)
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# Debug logging and plain-HTTP local development (default: false)
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# While true, the image routes (/v1/image/ and /v1/e/) answer every request
|
||||||
|
# for an image with 503 and a Retry-After header. The health check keeps
|
||||||
|
# answering 200 and reports maintenance_mode as true. It stays 200 because
|
||||||
|
# the image's Docker HEALTHCHECK requests it: a 503 there would make the
|
||||||
|
# container unhealthy, and upaas marks a deploy failed when its container is
|
||||||
|
# unhealthy. (default: false)
|
||||||
|
maintenance_mode: false
|
||||||
|
|
||||||
|
# Data directory for SQLite database and cache files
|
||||||
|
# (default: /var/lib/pixa)
|
||||||
|
state_dir: ./data
|
||||||
|
|
||||||
|
# SQLite database URL (default:
|
||||||
|
# file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds
|
||||||
|
# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the
|
||||||
|
# key out to use the default.
|
||||||
|
# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
|
|
||||||
|
# Image proxy settings
|
||||||
|
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
|
# Hosts that don't require signatures (default: none)
|
||||||
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
|
# An entry that is neither a host name nor an IP address (IPv6 without
|
||||||
|
# brackets), such as one with a port or a "*." wildcard, aborts startup.
|
||||||
|
allowlist_hosts:
|
||||||
|
- s3.sneak.cloud
|
||||||
|
- static.sneak.cloud
|
||||||
|
- sneak.berlin
|
||||||
|
- 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
|
||||||
|
# SSRF protection. These are added to the always-enforced built-in ranges
|
||||||
|
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
|
||||||
|
# similar), never replacing them. Each entry must be a valid CIDR in IPv4
|
||||||
|
# or IPv6 form; an invalid entry aborts startup. (default: none)
|
||||||
|
# blocked_networks:
|
||||||
|
# - 100.64.0.0/10
|
||||||
|
# - 2001:db8::/32
|
||||||
|
|
||||||
|
# CIDR ranges of the reverse proxies in front of pixa. X-Forwarded-For
|
||||||
|
# is believed only when the direct peer is inside one of these ranges;
|
||||||
|
# the client address in the access log and login records is then the
|
||||||
|
# rightmost forwarded entry that is not itself a trusted proxy. A client
|
||||||
|
# connecting directly (peer outside these ranges) cannot spoof its
|
||||||
|
# address: the header is ignored and the peer address is used. When
|
||||||
|
# omitted, this defaults to the RFC 1918 private ranges (10.0.0.0/8,
|
||||||
|
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
|
||||||
|
# a private network. An explicitly empty list ([]) trusts no one; an
|
||||||
|
# explicit list replaces the default. An invalid CIDR aborts startup.
|
||||||
|
# Uncomment to override the defaults with the address pixa sees for
|
||||||
|
# requests that come through your proxy. That is not always the proxy's own
|
||||||
|
# address: a proxy on the Docker host that connects over 127.0.0.1 is seen
|
||||||
|
# as the gateway of the container's Docker network (172.17.0.1 on the
|
||||||
|
# default bridge), and one that connects through another host address is
|
||||||
|
# seen with that address. To be sure, look it up in the request log as the
|
||||||
|
# trusted_proxies entry in README.md describes.
|
||||||
|
# trusted_proxies:
|
||||||
|
# - 10.0.0.0/8
|
||||||
|
# - 2001:db8::/32
|
||||||
|
|
||||||
|
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||||
|
# (default: false)
|
||||||
|
allow_http: false
|
||||||
|
|
||||||
|
# Maximum concurrent connections per upstream host (default: 20)
|
||||||
|
upstream_connections_per_host: 20
|
||||||
|
|
||||||
|
# Maximum concurrent connections to all upstream hosts together, on top of
|
||||||
|
# the per-host limit (default: 64). A fetch holds its connection until its
|
||||||
|
# image has been processed. A fetch that finds none free waits up to 10
|
||||||
|
# seconds for one, and if none frees up the request is answered 503, unless
|
||||||
|
# downstream_timeout has ended first.
|
||||||
|
upstream_connections: 64
|
||||||
|
|
||||||
|
# Maximum number of images decoded and encoded at once (default: the
|
||||||
|
# number of CPUs pixa can use, which follows a container's CPU limit). A
|
||||||
|
# request that finds none free waits up to 10 seconds for one, and if none
|
||||||
|
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||||
|
# max_concurrent_processing: 4
|
||||||
|
|
||||||
|
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||||
|
upstream_fetch_timeout: 30s
|
||||||
|
|
||||||
|
# Largest upstream response accepted, in bytes, at most 1073741824
|
||||||
|
# (1 GiB) (default: 52428800, 50 MiB)
|
||||||
|
upstream_max_response_size: 52428800
|
||||||
|
|
||||||
|
# Time allowed for answering one client request (default: 60s). The
|
||||||
|
# upstream fetch counts toward it, and so do the waits for an upstream
|
||||||
|
# connection and for a processing slot (up to 10 seconds each), so keep it
|
||||||
|
# longer than upstream_fetch_timeout plus 20 seconds.
|
||||||
|
downstream_timeout: 60s
|
||||||
|
|
||||||
|
# The origin a browser lets read the responses of the image routes,
|
||||||
|
# /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin
|
||||||
|
# header; no other route sends it. "*" (the default) is any site;
|
||||||
|
# otherwise one http or https origin such as https://example.com, whose
|
||||||
|
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
||||||
|
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
||||||
|
# shortest form), with an optional port 1-65535 that has no leading zero
|
||||||
|
# and is not the scheme's default. Any other value, including another
|
||||||
|
# scheme such as a browser extension's, aborts startup.
|
||||||
|
access_control_allow_origin: "*"
|
||||||
|
|
||||||
|
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
||||||
|
# given; 0 disables the disk cache entirely (every request fetches and
|
||||||
|
# processes uncached). When omitted, the default is 75% of the sum of
|
||||||
|
# the free space on the filesystem containing <state_dir>/cache/ and
|
||||||
|
# the bytes of images the cache already holds, worked out at startup,
|
||||||
|
# with a minimum of 500 MiB.
|
||||||
|
# cache_max_bytes: 10737418240
|
||||||
|
|
||||||
|
# Sentry DSN for error reporting (default: empty, which turns it off)
|
||||||
|
sentry_dsn: ""
|
||||||
|
|
||||||
|
# Username and password for /metrics, set together (default: unset). Metrics
|
||||||
|
# are measured and /metrics is served only when both are set.
|
||||||
|
# metrics:
|
||||||
|
# username: "admin"
|
||||||
|
# password: "secret"
|
||||||
|
|
||||||
|
# Environment variables set while this file loads, as described at the top
|
||||||
|
# (default: none)
|
||||||
|
# env:
|
||||||
|
# PIXA_DEBUG: "true"
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
|
||||||
|
# root only to make /var/lib/pixa usable by pixad: a host directory
|
||||||
|
# bind-mounted there keeps its host owner, often root, and data from an
|
||||||
|
# earlier run may belong to another uid. The server itself always runs
|
||||||
|
# as pixad.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
mkdir -p /var/lib/pixa
|
||||||
|
# Only the directory and its top-level entries are checked, so a
|
||||||
|
# normal start does not walk the cache. -depth gives each directory
|
||||||
|
# to pixad after its contents, so a start stopped part way leaves
|
||||||
|
# something at the top for the next start to find; -h changes a
|
||||||
|
# symlink itself, never the file it points to.
|
||||||
|
if [ -n "$(find /var/lib/pixa -maxdepth 1 \( ! -user pixad -o ! -group pixad \))" ]; then
|
||||||
|
find /var/lib/pixa -depth -exec chown -h pixad:pixad {} +
|
||||||
|
fi
|
||||||
|
chmod 750 /var/lib/pixa
|
||||||
|
exec su-exec pixad /usr/local/bin/pixad "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -11,12 +11,16 @@ require (
|
|||||||
github.com/getsentry/sentry-go v0.40.0
|
github.com/getsentry/sentry-go v0.40.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
|
github.com/go-chi/httprate v0.16.0
|
||||||
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.41.0
|
||||||
|
golang.org/x/sync v0.19.0
|
||||||
modernc.org/sqlite v1.42.2
|
modernc.org/sqlite v1.42.2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,6 +94,7 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
@@ -112,6 +117,7 @@ require (
|
|||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
||||||
@@ -129,7 +135,6 @@ require (
|
|||||||
golang.org/x/image v0.34.0 // indirect
|
golang.org/x/image v0.34.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/term v0.34.0 // indirect
|
golang.org/x/term v0.34.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
|||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
|
||||||
|
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
@@ -175,6 +177,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||||
|
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||||
|
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
@@ -224,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
|||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
@@ -249,6 +255,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -394,6 +402,10 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
|||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
|
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
package imgcache
|
// Package allowlist provides host-based URL allow-listing for the image proxy.
|
||||||
|
package allowlist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostWhitelist implements the Whitelist interface for checking allowed source hosts.
|
// HostAllowList checks whether source hosts are permitted.
|
||||||
type HostWhitelist struct {
|
type HostAllowList struct {
|
||||||
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
||||||
exactHosts map[string]struct{}
|
exactHosts map[string]struct{}
|
||||||
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
|
// suffixHosts contains domain suffixes to match
|
||||||
|
// (e.g., ".example.com" matches "cdn.example.com")
|
||||||
suffixHosts []string
|
suffixHosts []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostWhitelist creates a whitelist from a list of host patterns.
|
// New creates a HostAllowList from a list of host patterns.
|
||||||
// Patterns starting with "." are treated as suffix matches.
|
// Patterns starting with "." are treated as suffix matches.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - "cdn.example.com" - exact match only
|
// - "cdn.example.com" - exact match only
|
||||||
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
|
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
|
||||||
func NewHostWhitelist(patterns []string) *HostWhitelist {
|
func New(patterns []string) *HostAllowList {
|
||||||
w := &HostWhitelist{
|
w := &HostAllowList{
|
||||||
exactHosts: make(map[string]struct{}),
|
exactHosts: make(map[string]struct{}),
|
||||||
suffixHosts: make([]string, 0),
|
suffixHosts: make([]string, 0),
|
||||||
}
|
}
|
||||||
@@ -40,8 +42,8 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsWhitelisted checks if a URL's host is in the whitelist.
|
// IsAllowed checks if a URL's host is in the allow list.
|
||||||
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
|
func (w *HostAllowList) IsAllowed(u *url.URL) bool {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -71,12 +73,12 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty returns true if the whitelist has no entries.
|
// IsEmpty returns true if the allow list has no entries.
|
||||||
func (w *HostWhitelist) IsEmpty() bool {
|
func (w *HostAllowList) IsEmpty() bool {
|
||||||
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the total number of whitelist entries.
|
// Count returns the total number of allow list entries.
|
||||||
func (w *HostWhitelist) Count() int {
|
func (w *HostAllowList) Count() int {
|
||||||
return len(w.exactHosts) + len(w.suffixHosts)
|
return len(w.exactHosts) + len(w.suffixHosts)
|
||||||
}
|
}
|
||||||
@@ -1,119 +1,148 @@
|
|||||||
package imgcache
|
package allowlist_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHostWhitelist_IsWhitelisted(t *testing.T) {
|
const (
|
||||||
tests := []struct {
|
testExactHost = "cdn.example.com"
|
||||||
|
testImageURL = "https://cdn.example.com/image.jpg"
|
||||||
|
testSuffix = ".example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
type isAllowedCase struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
testURL string
|
testURL string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}
|
||||||
|
|
||||||
|
func runIsAllowedCases(t *testing.T, tests []isAllowedCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := allowlist.New(tt.patterns)
|
||||||
|
|
||||||
|
var u *url.URL
|
||||||
|
|
||||||
|
if tt.testURL != "" {
|
||||||
|
parsed, err := url.Parse(tt.testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse test URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
got := w.IsAllowed(u)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsAllowed() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostAllowList_IsAllowed_ExactMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runIsAllowedCases(t, []isAllowedCase{
|
||||||
{
|
{
|
||||||
name: "exact match",
|
name: "exact match",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exact match case insensitive",
|
name: "exact match case insensitive",
|
||||||
patterns: []string{"CDN.Example.COM"},
|
patterns: []string{"CDN.Example.COM"},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exact match not found",
|
name: "exact match not found",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "https://other.example.com/image.jpg",
|
testURL: "https://other.example.com/image.jpg",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "suffix match",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match deep subdomain",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://cdn.images.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match apex domain",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match not found",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://notexample.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match partial not allowed",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://fakeexample.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "multiple patterns",
|
name: "multiple patterns",
|
||||||
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
|
patterns: []string{testExactHost, ".images.org", "static.test.net"},
|
||||||
testURL: "https://photos.images.org/image.jpg",
|
testURL: "https://photos.images.org/image.jpg",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty whitelist",
|
name: "empty allow list",
|
||||||
patterns: []string{},
|
patterns: []string{},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nil url",
|
name: "nil url",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "",
|
testURL: "",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "url with port",
|
name: "url with port",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "https://cdn.example.com:443/image.jpg",
|
testURL: "https://cdn.example.com:443/image.jpg",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whitespace in patterns",
|
name: "whitespace in patterns",
|
||||||
patterns: []string{" cdn.example.com ", " .other.com "},
|
patterns: []string{" cdn.example.com ", " .other.com "},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
w := NewHostWhitelist(tt.patterns)
|
|
||||||
|
|
||||||
var u *url.URL
|
|
||||||
if tt.testURL != "" {
|
|
||||||
var err error
|
|
||||||
u, err = url.Parse(tt.testURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse test URL: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
got := w.IsWhitelisted(u)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("IsWhitelisted() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWhitelist_IsEmpty(t *testing.T) {
|
func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runIsAllowedCases(t, []isAllowedCase{
|
||||||
|
{
|
||||||
|
name: "suffix match",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match deep subdomain",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://cdn.images.example.com/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match apex domain",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://example.com/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match not found",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://notexample.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match partial not allowed",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://fakeexample.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostAllowList_IsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -143,7 +172,9 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
t.Parallel()
|
||||||
|
|
||||||
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.IsEmpty(); got != tt.want {
|
if got := w.IsEmpty(); got != tt.want {
|
||||||
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -151,7 +182,9 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWhitelist_Count(t *testing.T) {
|
func TestHostAllowList_Count(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -181,7 +214,9 @@ func TestHostWhitelist_Count(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
t.Parallel()
|
||||||
|
|
||||||
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.Count(); got != tt.want {
|
if got := w.Count(); got != tt.want {
|
||||||
t.Errorf("Count() = %v, want %v", got, tt.want)
|
t.Errorf("Count() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Package clientip resolves the real client IP address of an HTTP request
|
||||||
|
// when pixa runs behind a reverse proxy. Forwarding headers are believed
|
||||||
|
// only when the immediate peer is a configured trusted proxy, so an
|
||||||
|
// untrusted client cannot spoof its address by sending the header.
|
||||||
|
package clientip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForwardedForHeader is the request header carrying the proxy chain. It is
|
||||||
|
// honored only when the immediate peer is a trusted proxy.
|
||||||
|
const ForwardedForHeader = "X-Forwarded-For"
|
||||||
|
|
||||||
|
// Resolver determines the client IP of a request against a fixed set of
|
||||||
|
// trusted proxy networks.
|
||||||
|
type Resolver struct {
|
||||||
|
trusted []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResolver returns a Resolver that trusts forwarding headers only from
|
||||||
|
// peers inside the given CIDR ranges. A nil or empty list trusts no one,
|
||||||
|
// so the peer address is always used.
|
||||||
|
func NewResolver(trusted []netip.Prefix) *Resolver {
|
||||||
|
return &Resolver{trusted: trusted}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the client IP for a request whose direct peer is
|
||||||
|
// remoteAddr (a "host:port" string as in http.Request.RemoteAddr) and
|
||||||
|
// whose X-Forwarded-For header lines are forwardedFor (as returned by
|
||||||
|
// http.Header.Values). When the peer is not a trusted proxy, the peer
|
||||||
|
// address is returned and the header is ignored entirely. When the peer is
|
||||||
|
// trusted, the header is walked right to left and the first address that is
|
||||||
|
// not itself a trusted proxy is returned; this is the client the outermost
|
||||||
|
// trusted proxy observed, and entries an untrusted client may have prepended
|
||||||
|
// sit to its left and are never reached.
|
||||||
|
func (r *Resolver) Resolve(remoteAddr string, forwardedFor []string) string {
|
||||||
|
peer := hostOnly(remoteAddr)
|
||||||
|
|
||||||
|
peerAddr, err := netip.ParseAddr(peer)
|
||||||
|
if err != nil || !r.isTrusted(peerAddr) {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hop := range slices.Backward(forwardedForChain(forwardedFor)) {
|
||||||
|
hopAddr, err := netip.ParseAddr(hop)
|
||||||
|
if err != nil || r.isTrusted(hopAddr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return hopAddr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return peerAddr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTrusted reports whether addr falls inside one of the trusted proxy
|
||||||
|
// ranges. Addresses are unmapped first so an IPv4-mapped IPv6 form matches
|
||||||
|
// an IPv4 range, matching the fetcher's blocklist comparison.
|
||||||
|
func (r *Resolver) isTrusted(addr netip.Addr) bool {
|
||||||
|
if !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
unmapped := addr.Unmap()
|
||||||
|
|
||||||
|
return slices.ContainsFunc(r.trusted, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(unmapped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOnly strips the port from a "host:port" address. A value without a
|
||||||
|
// port (already a bare host) is returned unchanged.
|
||||||
|
func hostOnly(remoteAddr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return remoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedForChain flattens the comma-separated entries of every
|
||||||
|
// X-Forwarded-For header line into a single ordered, trimmed list.
|
||||||
|
func forwardedForChain(values []string) []string {
|
||||||
|
var chain []string
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
for part := range strings.SplitSeq(value, ",") {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed != "" {
|
||||||
|
chain = append(chain, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey is the private key type under which the resolved client IP is
|
||||||
|
// stored in a request context.
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
// WithClientIP returns a copy of ctx carrying the resolved client IP.
|
||||||
|
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKey{}, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromContext returns the resolved client IP stored in ctx, or an empty
|
||||||
|
// string if none was set.
|
||||||
|
func FromContext(ctx context.Context) string {
|
||||||
|
ip, _ := ctx.Value(contextKey{}).(string)
|
||||||
|
|
||||||
|
return ip
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package clientip_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addresses reused across the resolver cases.
|
||||||
|
const (
|
||||||
|
trustedRangeV4 = "10.0.0.0/8"
|
||||||
|
forwardedV4 = "203.0.113.7"
|
||||||
|
untrustedV4 = "198.51.100.9"
|
||||||
|
trustedPeer = "10.0.0.1:5000"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustPrefixes parses CIDR strings into prefixes for building a resolver.
|
||||||
|
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||||
|
|
||||||
|
for _, c := range cidrs {
|
||||||
|
p, err := netip.ParsePrefix(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes = append(prefixes, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolveCase struct {
|
||||||
|
name string
|
||||||
|
trusted []string
|
||||||
|
remoteAddr string
|
||||||
|
forwardedFor []string
|
||||||
|
want string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResolveCases runs each case against a resolver built from its trusted
|
||||||
|
// list and checks the resolved address.
|
||||||
|
func runResolveCases(t *testing.T, cases []resolveCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := clientip.NewResolver(mustPrefixes(t, tt.trusted...))
|
||||||
|
|
||||||
|
got := r.Resolve(tt.remoteAddr, tt.forwardedFor)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Resolve(%q, %v) = %q, want %q",
|
||||||
|
tt.remoteAddr, tt.forwardedFor, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolvePeerTrust covers the trust decision on the direct peer: a
|
||||||
|
// forwarded header is believed only from a trusted peer, and a client
|
||||||
|
// connecting directly cannot spoof its address.
|
||||||
|
func TestResolvePeerTrust(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runResolveCases(t, []resolveCase{
|
||||||
|
{
|
||||||
|
name: "trusted peer honors forwarded client",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "untrusted peer ignores forwarded header",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: untrustedV4 + ":33333",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: untrustedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spoofed chain from untrusted peer cannot influence result",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: untrustedV4 + ":33333",
|
||||||
|
forwardedFor: []string{"1.2.3.4, 10.9.9.9, 127.0.0.1"},
|
||||||
|
want: untrustedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty trusted list always uses peer",
|
||||||
|
trusted: nil,
|
||||||
|
remoteAddr: forwardedV4 + ":80",
|
||||||
|
forwardedFor: []string{"10.0.0.5"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted peer with no forwarded header uses peer",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: nil,
|
||||||
|
want: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable peer is returned unchanged",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: "garbage",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: "garbage",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveChainWalk covers walking the X-Forwarded-For chain from a
|
||||||
|
// trusted peer to the rightmost entry that is not itself a trusted proxy.
|
||||||
|
func TestResolveChainWalk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runResolveCases(t, []resolveCase{
|
||||||
|
{
|
||||||
|
name: "rightmost untrusted entry across a mixed chain",
|
||||||
|
trusted: []string{trustedRangeV4, "192.168.0.0/16"},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4 + ", 192.168.1.1, 10.0.0.2"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spoofed client behind a trusted proxy is not believed",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{"1.2.3.4, " + untrustedV4},
|
||||||
|
want: untrustedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain split across multiple header lines",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4, "10.0.0.2"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "garbage entries are skipped",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{forwardedV4 + ", not-an-ip"},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all-trusted chain falls back to peer",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: trustedPeer,
|
||||||
|
forwardedFor: []string{"10.0.0.9, 10.0.0.2"},
|
||||||
|
want: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted IPv6 peer honors forwarded client",
|
||||||
|
trusted: []string{"2001:db8::/32"},
|
||||||
|
remoteAddr: "[2001:db8::1]:9000",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4-mapped peer matches IPv4 trusted range",
|
||||||
|
trusted: []string{trustedRangeV4},
|
||||||
|
remoteAddr: "[::ffff:10.0.0.1]:5000",
|
||||||
|
forwardedFor: []string{forwardedV4},
|
||||||
|
want: forwardedV4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := clientip.WithClientIP(t.Context(), forwardedV4)
|
||||||
|
if got := clientip.FromContext(ctx); got != forwardedV4 {
|
||||||
|
t.Errorf("FromContext = %q, want %q", got, forwardedV4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromContextAbsent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := clientip.FromContext(t.Context()); got != "" {
|
||||||
|
t.Errorf("FromContext with no value = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
|
||||||
|
// each CIDR is parsed into the resolved prefixes in order.
|
||||||
|
func TestBlockedNetworksParsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `blocked_networks:
|
||||||
|
- 203.0.113.0/24
|
||||||
|
- 2001:db8::/32
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid blocked_networks should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"203.0.113.0/24", "2001:db8::/32"}
|
||||||
|
if len(c.BlockedNetworks) != len(want) {
|
||||||
|
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, w := range want {
|
||||||
|
if got := c.BlockedNetworks[i].String(); got != w {
|
||||||
|
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
|
||||||
|
// operator list empty; the built-in defaults still apply in the fetcher.
|
||||||
|
func TestBlockedNetworksOmittedIsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.BlockedNetworks) != 0 {
|
||||||
|
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
|
||||||
|
// startup with an error naming the key and the offending value.
|
||||||
|
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "not-a-cidr",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- not-a-cidr
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare-address-without-prefix",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- 10.0.0.1
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-entry",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- ""
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-string-entry",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- 42
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null-value",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
||||||
|
// explicitly configured cache_max_bytes value is used exactly as
|
||||||
|
// given: the 500 MiB floor applies only to the computed default, never
|
||||||
|
// to explicit values.
|
||||||
|
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 1024 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 1024 (no floor for explicit values)",
|
||||||
|
c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesZeroIsValidAndDisablesCache verifies that an
|
||||||
|
// explicit zero is a valid value (it disables the disk cache), not an
|
||||||
|
// error.
|
||||||
|
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 0 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 0", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
|
||||||
|
// 32-bit range parse correctly (the field is an int64 byte count).
|
||||||
|
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey +
|
||||||
|
"\ncache_max_bytes: 10737418240\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("large cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 10737418240 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 10737418240", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesInvalidValuesAbortStartup verifies that a SET but
|
||||||
|
// invalid cache_max_bytes value aborts startup naming the key and the
|
||||||
|
// offending value, per the no-silent-fallback rule: defaults apply
|
||||||
|
// only to omitted keys.
|
||||||
|
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "negative",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "-1024"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "3.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric string",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: banana\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit null",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: null\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare key no value",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes:\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: true\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes, "true"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
|
||||||
|
wantErrSubstrings: []string{keyCacheMaxBytes},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"config with %s cache_max_bytes must abort startup, got config: %+v",
|
||||||
|
tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
||||||
|
// cache_max_bytes is recorded as not explicit, so the cache works out
|
||||||
|
// the default when it opens, and that an explicit zero is recorded as
|
||||||
|
// explicit, so it disables the disk cache instead.
|
||||||
|
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
omitted, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if omitted.CacheMaxBytesExplicit {
|
||||||
|
t.Error("omitted cache_max_bytes recorded as explicit")
|
||||||
|
}
|
||||||
|
|
||||||
|
zero, err := configFromYAML(t, signingKeyLine+"cache_max_bytes: 0\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !zero.CacheMaxBytesExplicit {
|
||||||
|
t.Error("cache_max_bytes: 0 not recorded as explicit")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The variables that set the two concurrency limits.
|
||||||
|
const (
|
||||||
|
testMaxConcurrentProcessingVar = "PIXA_MAX_CONCURRENT_PROCESSING"
|
||||||
|
testUpstreamConnectionsVar = "PIXA_UPSTREAM_CONNECTIONS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
||||||
|
// max_concurrent_processing is the number of CPUs Go uses and an omitted
|
||||||
|
// upstream_connections is 64.
|
||||||
|
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != runtime.GOMAXPROCS(0) {
|
||||||
|
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
|
||||||
|
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnections != 64 {
|
||||||
|
t.Errorf("UpstreamConnections = %d, want 64", c.UpstreamConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
||||||
|
// two limits are used as given.
|
||||||
|
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
"max_concurrent_processing: 3\nupstream_connections: 10\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid config should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != 3 {
|
||||||
|
t.Errorf("MaxConcurrentProcessing = %d, want 3", c.MaxConcurrentProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnections != 10 {
|
||||||
|
t.Errorf("UpstreamConnections = %d, want 10", c.UpstreamConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidConcurrencyLimitAbortsStartup checks that a limit that is
|
||||||
|
// not a whole number of at least 1, or is null, aborts startup naming the
|
||||||
|
// key and the value, and the variable too where the value could have come
|
||||||
|
// from it.
|
||||||
|
func TestInvalidConcurrencyLimitAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
processing := keyMaxConcurrentProcessing
|
||||||
|
connections := keyUpstreamConnections
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing zero",
|
||||||
|
yaml: signingKeyLine + processing + ": 0\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
processing, testMaxConcurrentProcessingVar, "value 0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing negative",
|
||||||
|
yaml: signingKeyLine + processing + ": -2\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
processing, testMaxConcurrentProcessingVar, "value -2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing not a number",
|
||||||
|
yaml: signingKeyLine + processing + ": lots\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
processing, testMaxConcurrentProcessingVar, "lots",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing fractional",
|
||||||
|
yaml: signingKeyLine + processing + ": 1.5\n",
|
||||||
|
wantErrSubstrings: []string{processing, "1.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_concurrent_processing null",
|
||||||
|
yaml: signingKeyLine + processing + ": null\n",
|
||||||
|
wantErrSubstrings: []string{processing, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections zero",
|
||||||
|
yaml: signingKeyLine + connections + ": 0\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
connections, testUpstreamConnectionsVar, "value 0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections negative",
|
||||||
|
yaml: signingKeyLine + connections + ": -5\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
connections, testUpstreamConnectionsVar, "value -5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections not a number",
|
||||||
|
yaml: signingKeyLine + connections + ": many\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
connections, testUpstreamConnectionsVar, "many",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections null",
|
||||||
|
yaml: signingKeyLine + connections + ": null\n",
|
||||||
|
wantErrSubstrings: []string{connections, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrencyLimitsFromEnvironment checks that the two variables set
|
||||||
|
// the limits over the config file, and that an invalid value in either
|
||||||
|
// aborts startup naming the variable and the value.
|
||||||
|
func TestConcurrencyLimitsFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv(testMaxConcurrentProcessingVar, "3")
|
||||||
|
t.Setenv(testUpstreamConnectionsVar, "10")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
"max_concurrent_processing: 5\nupstream_connections: 50\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("limits from the environment should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxConcurrentProcessing != 3 || c.UpstreamConnections != 10 {
|
||||||
|
t.Errorf("limits = %d and %d, want 3 and 10 from the environment",
|
||||||
|
c.MaxConcurrentProcessing, c.UpstreamConnections)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
variable string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{testMaxConcurrentProcessingVar, "lots"},
|
||||||
|
{testMaxConcurrentProcessingVar, "0"},
|
||||||
|
{testUpstreamConnectionsVar, "-1"},
|
||||||
|
{testUpstreamConnectionsVar, "ten"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.variable+"="+tc.value, func(t *testing.T) {
|
||||||
|
t.Setenv(tc.variable, tc.value)
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, tc.variable, tc.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+1124
-59
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestConfig writes yamlContent to a temp config file and returns
|
||||||
|
// the file path.
|
||||||
|
func writeTestConfig(t *testing.T, yamlContent string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAllowlistHosts loads the config at configPath and asserts that
|
||||||
|
// getStringSlice returns the three expected hosts.
|
||||||
|
func checkAllowlistHosts(t *testing.T, configPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
|
||||||
|
if len(hosts) != 3 {
|
||||||
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
|
||||||
|
for i, want := range expected {
|
||||||
|
if i >= len(hosts) {
|
||||||
|
t.Errorf("missing host at index %d: want %q", i, want)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hosts[i] != want {
|
||||||
|
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := `
|
||||||
|
allowlist_hosts:
|
||||||
|
- static.sneak.cloud
|
||||||
|
- sneak.berlin
|
||||||
|
- s3.sneak.cloud
|
||||||
|
`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Backwards compatibility with comma-separated string values.
|
||||||
|
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := writeTestConfig(t, `port: 8080`)
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
if len(hosts) != 0 {
|
||||||
|
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTestConfig is a helper to load a config file for testing.
|
||||||
|
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||||
|
return smartconfig.NewFromConfigPath(path)
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetStringSlice_YAMLList(t *testing.T) {
|
|
||||||
// Create a temp config file with YAML list format
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `
|
|
||||||
whitelist_hosts:
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- s3.sneak.cloud
|
|
||||||
`
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config using smartconfig
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that getStringSlice correctly parses YAML list
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
|
||||||
for i, want := range expected {
|
|
||||||
if i >= len(hosts) {
|
|
||||||
t.Errorf("missing host at index %d: want %q", i, want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hosts[i] != want {
|
|
||||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|
||||||
// Test backwards compatibility with comma-separated string
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `whitelist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
|
||||||
for i, want := range expected {
|
|
||||||
if i >= len(hosts) {
|
|
||||||
t.Errorf("missing host at index %d: want %q", i, want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hosts[i] != want {
|
|
||||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringSlice_Empty(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `port: 8080`
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if hosts != nil && len(hosts) != 0 {
|
|
||||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadTestConfig is a helper to load a config file for testing
|
|
||||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
|
||||||
return smartconfig.NewFromConfigPath(path)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain unsets PORT and every PIXA_ environment variable before the
|
||||||
|
// tests run, so each test sees only the variables it sets itself, not
|
||||||
|
// whatever the shell running the tests exports.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
if name != "PORT" && !strings.HasPrefix(name, "PIXA_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.Unsetenv(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantStartupError fails the test unless err is a startup error that
|
||||||
|
// mentions every one of wants.
|
||||||
|
func wantStartupError(t *testing.T, err error, wants ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want a startup error mentioning %q, got none", wants)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range wants {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvironmentSetsEveryKey sets every key from its environment
|
||||||
|
// variable, with no config file at all: PORT for the port, and PIXA_
|
||||||
|
// plus the key in upper case, "." written as "_", for every other key.
|
||||||
|
func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_DEBUG", "true")
|
||||||
|
t.Setenv("PIXA_MAINTENANCE_MODE", "1")
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
t.Setenv("PIXA_STATE_DIR", "/srv/pixa-env")
|
||||||
|
t.Setenv("PIXA_SENTRY_DSN", "https://abc123@sentry.example.com/42")
|
||||||
|
t.Setenv("PIXA_DB_URL", "file:/srv/pixa-env/other.sqlite3")
|
||||||
|
t.Setenv("PIXA_METRICS_USERNAME", "metricsuser")
|
||||||
|
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
|
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
|
||||||
|
t.Setenv("PIXA_REFERER_BLOCKLIST", "hotlinker.example,.leech.example")
|
||||||
|
t.Setenv("PIXA_ALLOW_HTTP", "true")
|
||||||
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
||||||
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||||
|
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
||||||
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||||
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||||
|
t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com")
|
||||||
|
t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s")
|
||||||
|
t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576")
|
||||||
|
t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m")
|
||||||
|
|
||||||
|
c, err := newFromSmartConfig(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("configuration from the environment alone should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := Config{
|
||||||
|
Debug: true,
|
||||||
|
MaintenanceMode: true,
|
||||||
|
Port: 9090,
|
||||||
|
StateDir: "/srv/pixa-env",
|
||||||
|
SentryDSN: "https://abc123@sentry.example.com/42",
|
||||||
|
DBURL: "file:/srv/pixa-env/other.sqlite3",
|
||||||
|
MetricsUsername: "metricsuser",
|
||||||
|
MetricsPassword: "metricspass",
|
||||||
|
SigningKey: validTestSigningKey,
|
||||||
|
AllowlistHosts: []string{testHostS3, ".example.com"},
|
||||||
|
RefererBlocklist: []string{"hotlinker.example", ".leech.example"},
|
||||||
|
AllowHTTP: true,
|
||||||
|
UpstreamConnectionsPerHost: 5,
|
||||||
|
UpstreamConnections: 10,
|
||||||
|
MaxConcurrentProcessing: 3,
|
||||||
|
CacheMaxBytes: 1024,
|
||||||
|
CacheMaxBytesExplicit: true,
|
||||||
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
||||||
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
|
AccessControlAllowOrigin: "https://app.example.com",
|
||||||
|
UpstreamFetchTimeout: 10 * time.Second,
|
||||||
|
UpstreamMaxResponseSize: 1048576,
|
||||||
|
DownstreamTimeout: 2 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(*c, want) {
|
||||||
|
t.Errorf("config from the environment =\n%+v\nwant\n%+v", *c, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableAbortsStartup checks that a PIXA_ variable that
|
||||||
|
// is not a setting's variable, such as a misspelled one, aborts startup
|
||||||
|
// naming it, as an unknown config key does, instead of being ignored.
|
||||||
|
func TestUnknownPixaVariableAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
||||||
|
t.Setenv("PIXA_SIGNINGKEY", validTestSigningKey)
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY", "PIXA_SIGNINGKEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPixaPortAbortsStartupPointingToPort checks that PIXA_PORT aborts
|
||||||
|
// startup with a message saying to use PORT, which sets the port.
|
||||||
|
func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_PORT", "9090")
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
wantStartupError(t, err, "PIXA_PORT", "use PORT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
||||||
|
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
||||||
|
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
||||||
|
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||||
|
// A config file's env section loaded by another test can leave a
|
||||||
|
// PIXA_ variable set for the whole process, so every one is unset
|
||||||
|
// here first; t.Setenv restores each when the test ends.
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
if !strings.HasPrefix(name, "PIXA_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(name, "")
|
||||||
|
|
||||||
|
err := os.Unsetenv(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unset %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
||||||
|
|
||||||
|
for _, name := range envVarNames() {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := validateKnownEnvVars()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PIXA_CONFIG_PATH and every setting's variable "+
|
||||||
|
"must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFromNew writes yamlContent to a temporary config file, points
|
||||||
|
// PIXA_CONFIG_PATH at it, and runs New, as the server does at startup.
|
||||||
|
// The state directory is a temporary one and the disk cache is off, so
|
||||||
|
// New succeeds unless something in the test is wrong.
|
||||||
|
func configFromNew(t *testing.T, yamlContent string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", configPath)
|
||||||
|
t.Setenv("PIXA_STATE_DIR", filepath.Join(tmpDir, "state"))
|
||||||
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "0")
|
||||||
|
|
||||||
|
testLogger, err := logger.New(nil, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(nil, Params{Logger: testLogger})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableAbortsNew checks that New, which the server
|
||||||
|
// calls at startup, aborts on a misspelled PIXA_ variable.
|
||||||
|
func TestUnknownPixaVariableAbortsNew(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
|
||||||
|
|
||||||
|
_, err := configFromNew(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownPixaVariableInEnvSectionAbortsNew checks that New aborts
|
||||||
|
// on a misspelled PIXA_ name in the config file's env section, which
|
||||||
|
// loading the file sets as an environment variable.
|
||||||
|
func TestUnknownPixaVariableInEnvSectionAbortsNew(t *testing.T) {
|
||||||
|
// The variable must be absent until the file loads. t.Setenv makes
|
||||||
|
// sure the one the file sets is removed when the test ends.
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXY", "")
|
||||||
|
|
||||||
|
err := os.Unsetenv("PIXA_TRUSTED_PROXY")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unset PIXA_TRUSTED_PROXY: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = configFromNew(t, signingKeyLine+
|
||||||
|
"env:\n PIXA_TRUSTED_PROXY: 192.0.2.0/24\n")
|
||||||
|
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
|
||||||
|
// the port in the config file.
|
||||||
|
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"port: 8080\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PORT=9090 with port 8080 in the file should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9090 {
|
||||||
|
t.Errorf("Port = %d, want 9090 from PORT, not 8080 from the file", c.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidPortFromEnvironmentAbortsStartup checks that a PORT that is
|
||||||
|
// not a number, or is outside the port range, aborts startup naming PORT
|
||||||
|
// and the value, even though the file's port is valid.
|
||||||
|
func TestInvalidPortFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "banana")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine+"port: 8080\n")
|
||||||
|
wantStartupError(t, err, "PORT", "banana")
|
||||||
|
|
||||||
|
t.Setenv("PORT", "70000")
|
||||||
|
|
||||||
|
_, err = configFromYAML(t, signingKeyLine+"port: 8080\n")
|
||||||
|
wantStartupError(t, err, "PORT", "70000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFromEnvironmentReplacesConfigFileList checks that a list
|
||||||
|
// variable replaces the file's list, split on commas with the spaces
|
||||||
|
// around each entry trimmed.
|
||||||
|
func TestListFromEnvironmentReplacesConfigFileList(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_ALLOWLIST_HOSTS", " cdn.example.com , .example.org ")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+
|
||||||
|
"allowlist_hosts:\n - s3.sneak.cloud\n - sneak.berlin\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PIXA_ALLOWLIST_HOSTS should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"cdn.example.com", ".example.org"}
|
||||||
|
if !slices.Equal(c.AllowlistHosts, want) {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want %v from PIXA_ALLOWLIST_HOSTS",
|
||||||
|
c.AllowlistHosts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidBlockedNetworksFromEnvironmentAbortsStartup checks that an
|
||||||
|
// invalid CIDR, or an empty entry, in PIXA_BLOCKED_NETWORKS aborts
|
||||||
|
// startup naming the variable, as the same list in the file does.
|
||||||
|
func TestInvalidBlockedNetworksFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,not-a-cidr")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS", "not-a-cidr")
|
||||||
|
|
||||||
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,,198.51.100.0/24")
|
||||||
|
|
||||||
|
_, err = configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidDebugFromEnvironmentAbortsStartup checks that a PIXA_DEBUG
|
||||||
|
// that strconv.ParseBool rejects aborts startup instead of defaulting.
|
||||||
|
func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_DEBUG", "maybe")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, "PIXA_DEBUG", "maybe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that
|
||||||
|
// an invalid CORS origin, timeout or response size limit in its variable
|
||||||
|
// aborts startup naming the variable and the value.
|
||||||
|
func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
variable string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"},
|
||||||
|
{"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"},
|
||||||
|
{"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"},
|
||||||
|
{"PIXA_DOWNSTREAM_TIMEOUT", "0s"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.variable, func(t *testing.T) {
|
||||||
|
t.Setenv(tc.variable, tc.value)
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine)
|
||||||
|
wantStartupError(t, err, tc.variable, tc.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigFileAloneBehavesAsBefore checks that with no variables set
|
||||||
|
// (TestMain unsets them) the config file's values are used and omitted
|
||||||
|
// keys take their defaults.
|
||||||
|
func TestConfigFileAloneBehavesAsBefore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"port: 9191\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config file should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9191 {
|
||||||
|
t.Errorf("Port = %d, want 9191 from the file", c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != DefaultStateDir {
|
||||||
|
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(c.TrustedProxies, defaultTrustedProxies()) {
|
||||||
|
t.Errorf("TrustedProxies = %v, want default %v",
|
||||||
|
c.TrustedProxies, defaultTrustedProxies())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne checks that an empty
|
||||||
|
// PIXA_TRUSTED_PROXIES is an empty list, like [] in the file: it trusts
|
||||||
|
// no proxy instead of taking the default ranges.
|
||||||
|
func TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_TRUSTED_PROXIES", "")
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty PIXA_TRUSTED_PROXIES should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.TrustedProxies) != 0 {
|
||||||
|
t.Errorf("TrustedProxies = %v, want none", c.TrustedProxies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmptyVariableDoesNotFallBackToConfigFile checks that a variable
|
||||||
|
// that is present but empty is a set value: an empty PIXA_STATE_DIR
|
||||||
|
// aborts startup like state_dir: "" in the file, instead of falling
|
||||||
|
// through to the file's state_dir.
|
||||||
|
func TestEmptyVariableDoesNotFallBackToConfigFile(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_STATE_DIR", "")
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, signingKeyLine+"state_dir: /srv/pixa-file\n")
|
||||||
|
wantStartupError(t, err, "PIXA_STATE_DIR")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMissingSigningKeyNamesItsVariable checks that with no config file
|
||||||
|
// and no PIXA_SIGNING_KEY, startup aborts naming the variable, which is
|
||||||
|
// how a container started without it reports the problem.
|
||||||
|
func TestMissingSigningKeyNamesItsVariable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_SIGNING_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretsFromEnvironmentAreNotPrinted checks that errors about the
|
||||||
|
// signing key and the metrics password name their variables but never
|
||||||
|
// print their values.
|
||||||
|
func TestSecretsFromEnvironmentAreNotPrinted(t *testing.T) {
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", "short-signing-secret")
|
||||||
|
|
||||||
|
_, err := newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_SIGNING_KEY")
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), "short-signing-secret") {
|
||||||
|
t.Errorf("error %q prints the signing key", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
||||||
|
t.Setenv("PIXA_METRICS_PASSWORD", "metrics-password-secret")
|
||||||
|
|
||||||
|
_, err = newFromSmartConfig(nil)
|
||||||
|
wantStartupError(t, err, "PIXA_METRICS_PASSWORD")
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), "metrics-password-secret") {
|
||||||
|
t.Errorf("error %q prints the metrics password", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: an
|
||||||
|
// explicit CIDR list lands in TrustedProxies in order and replaces the
|
||||||
|
// default, an omitted key falls back to the RFC 1918 private ranges, and an
|
||||||
|
// explicitly empty list trusts no one. The list parser itself is shared with
|
||||||
|
// blocked_networks and is exercised in depth by that key's tests.
|
||||||
|
func TestTrustedProxiesConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("explicit list replaces the default in order", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t,
|
||||||
|
signingKeyLine+`trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]`+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid trusted_proxies should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(c.TrustedProxies))
|
||||||
|
for i, p := range c.TrustedProxies {
|
||||||
|
got[i] = p.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,2001:db8::/32" {
|
||||||
|
t.Errorf("TrustedProxies = %v, want the two ranges in order", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("omitted key defaults to the RFC 1918 ranges", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(c.TrustedProxies))
|
||||||
|
for i, p := range c.TrustedProxies {
|
||||||
|
got[i] = p.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
if joined := strings.Join(got, ","); joined != want {
|
||||||
|
t.Errorf("TrustedProxies = %v, want the RFC 1918 ranges %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicitly empty list trusts no one", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"trusted_proxies: []\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty trusted_proxies should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.TrustedProxies) != 0 {
|
||||||
|
t.Errorf("TrustedProxies = %v, want empty", c.TrustedProxies)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrustedProxiesInvalidAbortsStartup checks that an invalid or null
|
||||||
|
// value aborts startup with an error naming the key and the offending value.
|
||||||
|
func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "invalid cidr",
|
||||||
|
yaml: signingKeyLine + `trusted_proxies: ["999.0.0.0/8"]` + "\n",
|
||||||
|
wantErrSubstrings: []string{keyTrustedProxies, "999.0.0.0/8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null value",
|
||||||
|
yaml: signingKeyLine + "trusted_proxies:\n",
|
||||||
|
wantErrSubstrings: []string{keyTrustedProxies, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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, so the writes run on
|
||||||
|
// separate connections, 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
|
||||||
|
}
|
||||||
+108
-66
@@ -4,30 +4,39 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/db/migrations"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // SQLite driver registration
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed schema/*.sql
|
// bootstrapVersion is the migration that creates the schema_migrations
|
||||||
var schemaFS embed.FS
|
// table itself. It is applied before the normal migration loop.
|
||||||
|
const bootstrapVersion = 0
|
||||||
|
|
||||||
// Params defines dependencies for Database.
|
// Params defines dependencies for Database.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errInvalidMigrationFilename is returned when a migration filename does
|
||||||
|
// not match the "<version>[_<description>].sql" pattern.
|
||||||
|
var errInvalidMigrationFilename = errors.New("invalid migration filename")
|
||||||
|
|
||||||
// Database wraps the SQL database connection.
|
// Database wraps the SQL database connection.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -38,35 +47,38 @@ type Database struct {
|
|||||||
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
||||||
// filename. Filenames must follow the pattern "<version>.sql" or
|
// filename. Filenames must follow the pattern "<version>.sql" or
|
||||||
// "<version>_<description>.sql", where version is a zero-padded numeric
|
// "<version>_<description>.sql", where version is a zero-padded numeric
|
||||||
// string (e.g. "001", "002"). Returns the version string and an error if
|
// string (e.g. "001", "002"). Returns the version as an integer and an
|
||||||
// the filename does not match the expected pattern.
|
// error if the filename does not match the expected pattern.
|
||||||
func ParseMigrationVersion(filename string) (string, error) {
|
func ParseMigrationVersion(filename string) (int, error) {
|
||||||
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", fmt.Errorf("invalid migration filename %q: empty name", filename)
|
return 0, fmt.Errorf("%w %q: empty name", errInvalidMigrationFilename, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split on underscore to separate version from description.
|
// Split on underscore to separate version from description.
|
||||||
// If there's no underscore, the entire stem is the version.
|
// If there's no underscore, the entire stem is the version.
|
||||||
version := name
|
versionStr, _, _ := strings.Cut(name, "_")
|
||||||
if idx := strings.IndexByte(name, '_'); idx >= 0 {
|
if versionStr == "" {
|
||||||
version = name[:idx]
|
return 0, fmt.Errorf(
|
||||||
}
|
"%w %q: empty version prefix", errInvalidMigrationFilename, filename,
|
||||||
|
)
|
||||||
if version == "" {
|
|
||||||
return "", fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the version is purely numeric.
|
// Validate the version is purely numeric.
|
||||||
for _, ch := range version {
|
for _, ch := range versionStr {
|
||||||
if ch < '0' || ch > '9' {
|
if ch < '0' || ch > '9' {
|
||||||
return "", fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"invalid migration filename %q: version %q contains non-numeric character %q",
|
"%w %q: version %q contains non-numeric character %q",
|
||||||
filename, version, string(ch),
|
errInvalidMigrationFilename, filename, versionStr, string(ch),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version, err := strconv.Atoi(versionStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %q: %w", errInvalidMigrationFilename, filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +99,7 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
s.log.Info("Database OnStop Hook")
|
s.log.Info("Database OnStop Hook")
|
||||||
|
|
||||||
if s.db != nil {
|
if s.db != nil {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
@@ -98,62 +111,55 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) connect(ctx context.Context) error {
|
// collectMigrations reads the embedded migrations directory and returns
|
||||||
dbURL := s.config.DBURL
|
|
||||||
|
|
||||||
s.log.Info("connecting to database", "url", dbURL)
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", dbURL)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to open database", "error", err)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
|
||||||
s.log.Error("failed to ping database", "error", err)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.db = db
|
|
||||||
s.log.Info("database connected")
|
|
||||||
|
|
||||||
return ApplyMigrations(ctx, s.db, s.log)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectMigrations reads the embedded schema directory and returns
|
|
||||||
// migration filenames sorted lexicographically.
|
// migration filenames sorted lexicographically.
|
||||||
func collectMigrations() ([]string, error) {
|
func collectMigrations() ([]string, error) {
|
||||||
entries, err := schemaFS.ReadDir("schema")
|
entries, err := fs.ReadDir(migrations.FS(), ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations []string
|
var filenames []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
migrations = append(migrations, entry.Name())
|
filenames = append(filenames, entry.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(migrations)
|
sort.Strings(filenames)
|
||||||
|
|
||||||
return migrations, nil
|
return filenames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureMigrationsTable creates the schema_migrations tracking table if
|
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||||
// it does not already exist.
|
// by applying 000_migration.sql if the table is missing.
|
||||||
func ensureMigrationsTable(ctx context.Context, db *sql.DB) error {
|
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
var tableExists int
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
err := db.QueryRowContext(ctx,
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
)
|
).Scan(&tableExists)
|
||||||
`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fs.ReadFile(migrations.FS(), "000_migration.sql")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -164,16 +170,17 @@ func ensureMigrationsTable(ctx context.Context, db *sql.DB) error {
|
|||||||
// This is exported so tests can apply the real schema without the full fx
|
// This is exported so tests can apply the real schema without the full fx
|
||||||
// lifecycle.
|
// lifecycle.
|
||||||
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
if err := ensureMigrationsTable(ctx, db); err != nil {
|
err := bootstrapMigrationsTable(ctx, db, log)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations, err := collectMigrations()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, migration := range migrations {
|
filenames, err := collectMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range filenames {
|
||||||
version, parseErr := ParseMigrationVersion(migration)
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return parseErr
|
return parseErr
|
||||||
@@ -199,7 +206,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read and apply migration.
|
// Read and apply migration.
|
||||||
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
|
content, readErr := fs.ReadFile(migrations.FS(), migration)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
||||||
}
|
}
|
||||||
@@ -234,3 +241,38 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|||||||
func (s *Database) DB() *sql.DB {
|
func (s *Database) DB() *sql.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Database) connect(ctx context.Context) error {
|
||||||
|
// Requests and the eviction pass write on separate connections. With
|
||||||
|
// a busy timeout, a write that finds another one in progress 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)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to open database", "error", err)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.PingContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db = db
|
||||||
|
s.log.Info("database connected")
|
||||||
|
|
||||||
|
return ApplyMigrations(ctx, s.db, s.log)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
|
)
|
||||||
|
|
||||||
|
// openTestDB returns a fresh in-memory SQLite database.
|
||||||
|
func openTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "version only",
|
||||||
|
filename: "001.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version with description",
|
||||||
|
filename: "001_initial_schema.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-digit version",
|
||||||
|
filename: "042_add_indexes.sql",
|
||||||
|
want: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long version number",
|
||||||
|
filename: "00001_long_prefix.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description with multiple underscores",
|
||||||
|
filename: "003_add_user_auth_tables.sql",
|
||||||
|
want: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty filename",
|
||||||
|
filename: ".sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading underscore",
|
||||||
|
filename: "_description.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric version",
|
||||||
|
filename: "abc_migration.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed alphanumeric version",
|
||||||
|
filename: "001a_migration.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseMigrationVersion(tt.filename)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) expected error, got %d", tt.filename, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) = %d, want %d", tt.filename, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The schema_migrations table must exist and contain at least
|
||||||
|
// version 0 (the bootstrap) and 1 (the initial schema).
|
||||||
|
rows, err := db.QueryContext(
|
||||||
|
ctx, "SELECT version FROM schema_migrations ORDER BY version",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var versions []int
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var v int
|
||||||
|
|
||||||
|
scanErr := rows.Scan(&v)
|
||||||
|
if scanErr != nil {
|
||||||
|
t.Fatalf("failed to scan version: %v", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("row iteration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) < 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected at least 2 migrations recorded, got %d: %v",
|
||||||
|
len(versions), versions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if versions[0] != 0 {
|
||||||
|
t.Errorf("first recorded migration = %d, want %d", versions[0], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if versions[1] != 1 {
|
||||||
|
t.Errorf("second recorded migration = %d, want %d", versions[1], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the application tables created by 001.sql exist.
|
||||||
|
tables := []string{
|
||||||
|
"source_content", "source_metadata", "output_content",
|
||||||
|
"request_cache", "negative_cache", "cache_stats",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
table,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table %s: %v", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("table %s does not exist after migrations", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_Idempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running a second time must succeed without errors.
|
||||||
|
err = ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no duplicate rows in schema_migrations.
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count version 0 rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected exactly 1 row for version 0, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := bootstrapMigrationsTable(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schema_migrations table must exist.
|
||||||
|
var tableCount int
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableCount != 1 {
|
||||||
|
t.Fatalf("schema_migrations table not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version 0 must be recorded.
|
||||||
|
var recorded int
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&recorded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded != 1 {
|
||||||
|
t.Errorf("expected version 0 to be recorded, got count %d", recorded)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // SQLite driver registration
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseMigrationVersion(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
filename string
|
|
||||||
want string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "version only",
|
|
||||||
filename: "001.sql",
|
|
||||||
want: "001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "version with description",
|
|
||||||
filename: "001_initial_schema.sql",
|
|
||||||
want: "001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multi-digit version",
|
|
||||||
filename: "042_add_indexes.sql",
|
|
||||||
want: "042",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "long version number",
|
|
||||||
filename: "00001_long_prefix.sql",
|
|
||||||
want: "00001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "description with multiple underscores",
|
|
||||||
filename: "003_add_user_auth_tables.sql",
|
|
||||||
want: "003",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty filename",
|
|
||||||
filename: ".sql",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "leading underscore",
|
|
||||||
filename: "_description.sql",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-numeric version",
|
|
||||||
filename: "abc_migration.sql",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed alphanumeric version",
|
|
||||||
filename: "001a_migration.sql",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := ParseMigrationVersion(tt.filename)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("ParseMigrationVersion(%q) expected error, got %q", tt.filename, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("ParseMigrationVersion(%q) = %q, want %q", tt.filename, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMigrations(t *testing.T) {
|
|
||||||
db, err := sql.Open("sqlite", ":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open in-memory database: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// Apply migrations should succeed.
|
|
||||||
if err := ApplyMigrations(context.Background(), db, nil); err != nil {
|
|
||||||
t.Fatalf("ApplyMigrations failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the schema_migrations table recorded the version.
|
|
||||||
var version string
|
|
||||||
|
|
||||||
err = db.QueryRowContext(context.Background(),
|
|
||||||
"SELECT version FROM schema_migrations LIMIT 1",
|
|
||||||
).Scan(&version)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to query schema_migrations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if version != "001" {
|
|
||||||
t.Errorf("expected version %q, got %q", "001", version)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify a table from the migration exists (source_content).
|
|
||||||
var tableName string
|
|
||||||
|
|
||||||
err = db.QueryRowContext(context.Background(),
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_content'",
|
|
||||||
).Scan(&tableName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected source_content table to exist: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMigrationsIdempotent(t *testing.T) {
|
|
||||||
db, err := sql.Open("sqlite", ":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open in-memory database: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// Apply twice should succeed (idempotent).
|
|
||||||
if err := ApplyMigrations(context.Background(), db, nil); err != nil {
|
|
||||||
t.Fatalf("first ApplyMigrations failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ApplyMigrations(context.Background(), db, nil); err != nil {
|
|
||||||
t.Fatalf("second ApplyMigrations failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should still have exactly one migration recorded.
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(context.Background(),
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to count schema_migrations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("expected 1 migration record, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestApplyMigrations_SecondRunAppliesNothing applies the migrations twice
|
||||||
|
// to one database file, as happens when pixad starts again on the database
|
||||||
|
// it created, and checks that the second run applies none of them.
|
||||||
|
// ApplyMigrations logs a message starting with "applying" before it runs
|
||||||
|
// any migration, the bootstrap one included.
|
||||||
|
func TestApplyMigrations_SecondRunAppliesNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.sqlite3"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
var firstLog bytes.Buffer
|
||||||
|
|
||||||
|
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&firstLog, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(firstLog.String(), "applying") {
|
||||||
|
t.Fatalf("first ApplyMigrations logged no applied migration:\n%s",
|
||||||
|
firstLog.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondLog bytes.Buffer
|
||||||
|
|
||||||
|
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&secondLog, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(secondLog.String(), "applying") {
|
||||||
|
t.Errorf("second ApplyMigrations ran a migration again:\n%s",
|
||||||
|
secondLog.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 000: Schema migrations tracking table
|
||||||
|
-- Applied as a bootstrap step before the normal migration loop.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
+26
-4
@@ -2,16 +2,21 @@
|
|||||||
-- Creates all tables for the pixa caching image proxy
|
-- Creates all tables for the pixa caching image proxy
|
||||||
|
|
||||||
-- Source content blobs
|
-- Source content blobs
|
||||||
-- Files stored at: cache/src-content/<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
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
|
ON source_content(last_accessed_at);
|
||||||
|
|
||||||
-- Source URL metadata - maps URLs to content hashes
|
-- Source URL metadata - maps URLs to content hashes
|
||||||
-- JSON stored at: cache/src-metadata/<hostname>/<path_hash>.json
|
-- JSON stored at: cache/metadata/<hostname>/<path_hash>.json
|
||||||
CREATE TABLE IF NOT EXISTS source_metadata (
|
CREATE TABLE IF NOT EXISTS source_metadata (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
@@ -34,8 +39,25 @@ CREATE INDEX IF NOT EXISTS idx_source_meta_path_hash ON source_metadata(path_has
|
|||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
||||||
|
|
||||||
|
-- Processed variant blobs
|
||||||
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
||||||
|
-- sidecar with the content type). Tracked here (like source content
|
||||||
|
-- blobs above) so total cache usage can be computed with a SUM query,
|
||||||
|
-- never a directory scan, and so LRU eviction has a timestamp to order
|
||||||
|
-- on.
|
||||||
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
|
ON variant_content(last_accessed_at);
|
||||||
|
|
||||||
-- Output/transformed content blobs
|
-- Output/transformed content blobs
|
||||||
-- Files stored at: cache/dst-content/<ab>/<cd>/<sha256>
|
-- Not written: transformed images are stored in cache/variants and
|
||||||
|
-- tracked in variant_content above.
|
||||||
CREATE TABLE IF NOT EXISTS output_content (
|
CREATE TABLE IF NOT EXISTS output_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
content_hash TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Package migrations provides the embedded SQL migration files.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
// FS returns the embedded filesystem containing the migration files.
|
||||||
|
func FS() fs.FS {
|
||||||
|
return files
|
||||||
|
}
|
||||||
@@ -48,7 +48,8 @@ type Generator struct {
|
|||||||
key [seal.KeySize]byte
|
key [seal.KeySize]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGenerator creates an encrypted URL generator with a key derived from the signing key.
|
// NewGenerator creates an encrypted URL generator with a key derived
|
||||||
|
// from the signing key.
|
||||||
func NewGenerator(signingKey string) (*Generator, error) {
|
func NewGenerator(signingKey string) (*Generator, error) {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +78,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
// Decrypt
|
// Decrypt
|
||||||
data, err := seal.Decrypt(g.key, token)
|
data, err := seal.Decrypt(g.key, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) {
|
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
||||||
|
errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
return nil, ErrDecryptFailed
|
return nil, ErrDecryptFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +88,9 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
var p Payload
|
var p Payload
|
||||||
if err := cbor.Unmarshal(data, &p); err != nil {
|
|
||||||
|
err = cbor.Unmarshal(data, &p)
|
||||||
|
if err != nil {
|
||||||
return nil, ErrInvalidFormat
|
return nil, ErrInvalidFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +103,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ToImageRequest converts the payload to an ImageRequest.
|
// ToImageRequest converts the payload to an ImageRequest.
|
||||||
// Applies default values for omitted optional fields.
|
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
||||||
|
// that never expires, gives the zero Expires.
|
||||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
@@ -116,6 +121,11 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
fitMode = DefaultFitMode
|
fitMode = DefaultFitMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var expires time.Time
|
||||||
|
if p.ExpiresAt != 0 {
|
||||||
|
expires = time.Unix(p.ExpiresAt, 0)
|
||||||
|
}
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imgcache.ImageRequest{
|
||||||
SourceHost: p.SourceHost,
|
SourceHost: p.SourceHost,
|
||||||
SourcePath: p.SourcePath,
|
SourcePath: p.SourcePath,
|
||||||
@@ -127,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
|
Expires: expires,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-50
@@ -1,22 +1,33 @@
|
|||||||
package encurl
|
package encurl_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared test fixture strings.
|
||||||
|
const (
|
||||||
|
testSourceHost = "cdn.example.com"
|
||||||
|
testSourcePath = "/images/photo.jpg"
|
||||||
|
testSourceQuery = "v=2"
|
||||||
|
)
|
||||||
|
|
||||||
func TestGenerator_GenerateAndParse(t *testing.T) {
|
func TestGenerator_GenerateAndParse(t *testing.T) {
|
||||||
gen, err := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
|
gen, err := encurl.NewGenerator("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGenerator() error = %v", err)
|
t.Fatalf("NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &Payload{
|
payload := &encurl.Payload{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: testSourceQuery,
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
@@ -43,38 +54,48 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
if parsed.SourceHost != payload.SourceHost {
|
if parsed.SourceHost != payload.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourcePath != payload.SourcePath {
|
if parsed.SourcePath != payload.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourceQuery != payload.SourceQuery {
|
if parsed.SourceQuery != payload.SourceQuery {
|
||||||
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Width != payload.Width {
|
if parsed.Width != payload.Width {
|
||||||
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Height != payload.Height {
|
if parsed.Height != payload.Height {
|
||||||
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Format != payload.Format {
|
if parsed.Format != payload.Format {
|
||||||
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Quality != payload.Quality {
|
if parsed.Quality != payload.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.FitMode != payload.FitMode {
|
if parsed.FitMode != payload.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.ExpiresAt != payload.ExpiresAt {
|
if parsed.ExpiresAt != payload.ExpiresAt {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_Expired(t *testing.T) {
|
func TestGenerator_Parse_Expired(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +109,15 @@ func TestGenerator_Parse_Expired(t *testing.T) {
|
|||||||
t.Error("Parse() should fail for expired token")
|
t.Error("Parse() should fail for expired token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrExpired {
|
if !errors.Is(err, encurl.ErrExpired) {
|
||||||
t.Errorf("Parse() error = %v, want %v", err, ErrExpired)
|
t.Errorf("Parse() error = %v, want %v", err, encurl.ErrExpired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
|
|
||||||
_, err := gen.Parse("not-a-valid-token")
|
_, err := gen.Parse("not-a-valid-token")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -103,11 +126,13 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +151,14 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
||||||
gen1, _ := NewGenerator("signing-key-1")
|
t.Parallel()
|
||||||
gen2, _ := NewGenerator("signing-key-2")
|
|
||||||
|
|
||||||
payload := &Payload{
|
gen1, _ := encurl.NewGenerator("signing-key-1")
|
||||||
SourceHost: "cdn.example.com",
|
gen2, _ := encurl.NewGenerator("signing-key-2")
|
||||||
SourcePath: "/images/photo.jpg",
|
|
||||||
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +171,12 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest(t *testing.T) {
|
func TestPayload_ToImageRequest(t *testing.T) {
|
||||||
payload := &Payload{
|
t.Parallel()
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
SourceQuery: "v=2",
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
|
SourceQuery: testSourceQuery,
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
@@ -161,55 +190,68 @@ func TestPayload_ToImageRequest(t *testing.T) {
|
|||||||
if req.SourceHost != payload.SourceHost {
|
if req.SourceHost != payload.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != payload.SourcePath {
|
if req.SourcePath != payload.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != payload.SourceQuery {
|
if req.SourceQuery != payload.SourceQuery {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Width != payload.Width {
|
if req.Size.Width != payload.Width {
|
||||||
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Height != payload.Height {
|
if req.Size.Height != payload.Height {
|
||||||
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Format != payload.Format {
|
if req.Format != payload.Format {
|
||||||
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Quality != payload.Quality {
|
if req.Quality != payload.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.FitMode != payload.FitMode {
|
if req.FitMode != payload.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Payload with only required fields - should get defaults
|
// Payload with only required fields - should get defaults
|
||||||
payload := &Payload{
|
payload := &encurl.Payload{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
if req.Format != DefaultFormat {
|
if req.Format != encurl.DefaultFormat {
|
||||||
t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat)
|
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
|
||||||
}
|
}
|
||||||
if req.Quality != DefaultQuality {
|
|
||||||
t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality)
|
if req.Quality != encurl.DefaultQuality {
|
||||||
|
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
if req.FitMode != DefaultFitMode {
|
|
||||||
t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode)
|
if req.FitMode != encurl.DefaultFitMode {
|
||||||
|
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromImageRequest(t *testing.T) {
|
func TestFromImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &imgcache.ImageRequest{
|
req := &imgcache.ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: testSourceQuery,
|
||||||
Size: imgcache.Size{Width: 800, Height: 600},
|
Size: imgcache.Size{Width: 800, Height: 600},
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
Quality: 90,
|
Quality: 90,
|
||||||
@@ -217,52 +259,62 @@ func TestFromImageRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Hour)
|
expiresAt := time.Now().Add(time.Hour)
|
||||||
payload := FromImageRequest(req, expiresAt)
|
payload := encurl.FromImageRequest(req, expiresAt)
|
||||||
|
|
||||||
if payload.SourceHost != req.SourceHost {
|
if payload.SourceHost != req.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.SourcePath != req.SourcePath {
|
if payload.SourcePath != req.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Width != req.Size.Width {
|
if payload.Width != req.Size.Width {
|
||||||
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.ExpiresAt != expiresAt.Unix() {
|
if payload.ExpiresAt != expiresAt.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
||||||
// Request with default values - payload should omit them for smaller encoding
|
t.Parallel()
|
||||||
|
|
||||||
|
// Request with default values - payload should omit them for
|
||||||
|
// smaller encoding
|
||||||
req := &imgcache.ImageRequest{
|
req := &imgcache.ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
Format: DefaultFormat,
|
Format: encurl.DefaultFormat,
|
||||||
Quality: DefaultQuality,
|
Quality: encurl.DefaultQuality,
|
||||||
FitMode: DefaultFitMode,
|
FitMode: encurl.DefaultFitMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := FromImageRequest(req, time.Now().Add(time.Hour))
|
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// These should be zero/empty because they match defaults
|
// These should be zero/empty because they match defaults
|
||||||
if payload.Format != "" {
|
if payload.Format != "" {
|
||||||
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Quality != 0 {
|
if payload.Quality != 0 {
|
||||||
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.FitMode != "" {
|
if payload.FitMode != "" {
|
||||||
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build-time variables populated from main() via ldflags.
|
const appname = "pixad"
|
||||||
var (
|
|
||||||
Appname string //nolint:gochecknoglobals // set from main
|
// Version is set by app.Run to the version main was built with.
|
||||||
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 {
|
||||||
@@ -20,7 +19,7 @@ type Globals struct {
|
|||||||
// New creates a new Globals instance from build-time variables.
|
// New creates a new Globals instance from build-time variables.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
return &Globals{
|
return &Globals{
|
||||||
Appname: Appname,
|
Appname: appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+218
-78
@@ -1,17 +1,37 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errInvalidFormField reports a generator form field, or the q or exp
|
||||||
|
// parameter of /v1/image/, whose value is non-numeric or out of range. The
|
||||||
|
// offending field name is wrapped in so the response can name it.
|
||||||
|
var errInvalidFormField = errors.New("invalid")
|
||||||
|
|
||||||
|
// Bounds for the generator's quality and ttl fields; the quality bounds also
|
||||||
|
// apply to the q parameter of /v1/image/. maxTTL is in seconds: the expiry
|
||||||
|
// calculation time.Duration(ttl) * time.Second overflows above it.
|
||||||
|
const (
|
||||||
|
minQuality = 1
|
||||||
|
maxQuality = 100
|
||||||
|
maxTTL = int(math.MaxInt64 / time.Second)
|
||||||
|
)
|
||||||
|
|
||||||
// HandleRoot serves the login page or generator page based on authentication state.
|
// HandleRoot serves the login page or generator page based on authentication state.
|
||||||
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,20 +43,21 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
|
|
||||||
// Check if authenticated
|
// Check if authenticated
|
||||||
if s.sessMgr.IsAuthenticated(r) {
|
if s.sessMgr.IsAuthenticated(r) {
|
||||||
s.renderGenerator(w, nil)
|
s.renderGenerator(w, r, nil)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login page
|
// Show login page
|
||||||
s.renderLogin(w, "")
|
s.renderLogin(w, r, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLoginPost handles login form submission.
|
// handleLoginPost handles login form submission.
|
||||||
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
s.renderLogin(w, "Invalid form data")
|
if err != nil {
|
||||||
|
s.renderLogin(w, r, "Invalid form data")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -45,21 +66,24 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
||||||
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
s.log.Warn("failed login attempt",
|
||||||
s.renderLogin(w, "Invalid signing key")
|
"remote_addr", clientip.FromContext(r.Context()))
|
||||||
|
s.renderLogin(w, r, "Invalid signing key")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
if err := s.sessMgr.CreateSession(w); err != nil {
|
err = s.sessMgr.CreateSession(w)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to create session", "error", err)
|
s.log.Error("failed to create session", "error", err)
|
||||||
s.renderLogin(w, "Failed to create session")
|
s.renderLogin(w, r, "Failed to create session")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
|
s.log.Info("successful login",
|
||||||
|
"remote_addr", clientip.FromContext(r.Context()))
|
||||||
|
|
||||||
// Redirect to generator page
|
// Redirect to generator page
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -83,85 +107,43 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
if err != nil {
|
||||||
|
s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse form values
|
|
||||||
sourceURL := r.FormValue("url")
|
sourceURL := r.FormValue("url")
|
||||||
widthStr := r.FormValue("width")
|
|
||||||
heightStr := r.FormValue("height")
|
|
||||||
format := r.FormValue("format")
|
|
||||||
qualityStr := r.FormValue("quality")
|
|
||||||
fit := r.FormValue("fit")
|
|
||||||
ttlStr := r.FormValue("ttl")
|
|
||||||
|
|
||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, "Invalid source URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse dimensions
|
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
||||||
width, _ := strconv.Atoi(widthStr)
|
if err != nil {
|
||||||
height, _ := strconv.Atoi(heightStr)
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
quality, _ := strconv.Atoi(qualityStr)
|
http.StatusBadRequest)
|
||||||
ttl, _ := strconv.Atoi(ttlStr)
|
|
||||||
|
|
||||||
if quality <= 0 {
|
return
|
||||||
quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create payload
|
|
||||||
// ttl=0 means never expires
|
|
||||||
var expiresAt time.Time
|
|
||||||
var expiresAtUnix int64
|
|
||||||
|
|
||||||
if ttl > 0 {
|
|
||||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
|
||||||
expiresAtUnix = expiresAt.Unix()
|
|
||||||
}
|
|
||||||
// else expiresAtUnix stays 0 (never expires)
|
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
|
||||||
SourceHost: parsed.Host,
|
|
||||||
SourcePath: parsed.Path,
|
|
||||||
SourceQuery: parsed.RawQuery,
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
Format: imgcache.ImageFormat(format),
|
|
||||||
Quality: quality,
|
|
||||||
FitMode: imgcache.FitMode(fit),
|
|
||||||
ExpiresAt: expiresAtUnix,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build full URL (URL-encode the token for safety)
|
generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format"))
|
||||||
scheme := "https"
|
|
||||||
if s.config.Debug {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine file extension for the trailing filename
|
|
||||||
ext := format
|
|
||||||
if ext == "" || ext == "orig" {
|
|
||||||
ext = "jpg" // Default extension
|
|
||||||
}
|
|
||||||
|
|
||||||
host := r.Host
|
|
||||||
generatedURL := scheme + "://" + host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
|
||||||
|
|
||||||
// Format expiry for display
|
// Format expiry for display
|
||||||
expiresAtStr := "Never"
|
expiresAtStr := "Never"
|
||||||
@@ -169,20 +151,128 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
expiresAtStr = expiresAt.Format(time.RFC3339)
|
expiresAtStr = expiresAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.renderGenerator(w, &generatorData{
|
s.renderGenerator(w, r, &generatorData{
|
||||||
GeneratedURL: generatedURL,
|
GeneratedURL: generatedURL,
|
||||||
ExpiresAt: expiresAtStr,
|
ExpiresAt: expiresAtStr,
|
||||||
FormURL: sourceURL,
|
FormURL: sourceURL,
|
||||||
FormWidth: widthStr,
|
FormWidth: r.FormValue("width"),
|
||||||
FormHeight: heightStr,
|
FormHeight: r.FormValue("height"),
|
||||||
FormFormat: format,
|
FormFormat: r.FormValue("format"),
|
||||||
FormQuality: qualityStr,
|
FormQuality: r.FormValue("quality"),
|
||||||
FormFit: fit,
|
FormFit: r.FormValue("fit"),
|
||||||
FormTTL: ttlStr,
|
FormTTL: r.FormValue("ttl"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
|
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
||||||
|
// non-numeric or out-of-range width, height, quality or ttl, or an
|
||||||
|
// unrecognized fit mode, is a client error naming the offending field. The
|
||||||
|
// format field is passed through unchecked.
|
||||||
|
func buildGeneratePayload(
|
||||||
|
parsed *url.URL, form url.Values,
|
||||||
|
) (*encurl.Payload, time.Time, int, error) {
|
||||||
|
width, err := parseFormDimension(form, "width")
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
height, err := parseFormDimension(form, "height")
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quality, err := parseFormInt(form, "quality",
|
||||||
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl, err := parseFormInt(form, "ttl", 0, 0, maxTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fitMode := imgcache.FitMode(form.Get("fit"))
|
||||||
|
|
||||||
|
err = imgcache.ValidateFitMode(fitMode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0,
|
||||||
|
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
expiresAt time.Time
|
||||||
|
expiresAtUnix int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if ttl > 0 {
|
||||||
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
|
expiresAtUnix = expiresAt.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: parsed.Host,
|
||||||
|
SourcePath: parsed.Path,
|
||||||
|
SourceQuery: parsed.RawQuery,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Format: imgcache.ImageFormat(form.Get("format")),
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: fitMode,
|
||||||
|
ExpiresAt: expiresAtUnix,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, expiresAt, ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
|
// value means "original size" (0). A non-numeric value, or one
|
||||||
|
// imgcache.ValidateDimension rejects, is an error naming the field.
|
||||||
|
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = imgcache.ValidateDimension(field, value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormInt reads an optional integer form field or URL query parameter,
|
||||||
|
// returning def when the field is empty and an error naming the field when the
|
||||||
|
// value is non-numeric or outside minValue to maxValue.
|
||||||
|
func parseFormInt(
|
||||||
|
form url.Values, field string, def, minValue, maxValue int,
|
||||||
|
) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value < minValue || value > maxValue {
|
||||||
|
return 0, fmt.Errorf("%w %s: must be from %d to %d",
|
||||||
|
errInvalidFormField, field, minValue, maxValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
// generatorData holds template data for the generator page.
|
// generatorData holds template data for the generator page.
|
||||||
type generatorData struct {
|
type generatorData struct {
|
||||||
GeneratedURL string
|
GeneratedURL string
|
||||||
@@ -195,38 +285,72 @@ type generatorData struct {
|
|||||||
FormQuality string
|
FormQuality string
|
||||||
FormFit string
|
FormFit string
|
||||||
FormTTL string
|
FormTTL string
|
||||||
|
CSRFField template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
|
func (s *Handlers) renderLogin(
|
||||||
|
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||||
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Error string
|
Error string
|
||||||
|
CSRFField template.HTML
|
||||||
}{
|
}{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
|
CSRFField: csrfField(r),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.Render(w, "login.html", data); err != nil {
|
err := templates.Render(w, "login.html", data)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to render login template", "error", err)
|
s.log.Error("failed to render login template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
|
func (s *Handlers) renderGenerator(
|
||||||
|
w http.ResponseWriter, r *http.Request, data *generatorData,
|
||||||
|
) {
|
||||||
|
s.renderGeneratorStatus(w, r, data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGeneratorStatus renders the generator page with an explicit HTTP
|
||||||
|
// status; a rejected form uses 400. The page is rendered into a buffer before
|
||||||
|
// the status is written, so a template failure can still answer 500.
|
||||||
|
func (s *Handlers) renderGeneratorStatus(
|
||||||
|
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
|
||||||
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = &generatorData{}
|
data = &generatorData{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.Render(w, "generator.html", data); err != nil {
|
data.CSRFField = csrfField(r)
|
||||||
|
|
||||||
|
var page bytes.Buffer
|
||||||
|
|
||||||
|
err := templates.Render(&page, "generator.html", data)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = page.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write generator page", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) {
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
s.renderGenerator(w, &generatorData{
|
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||||
|
form url.Values, status int,
|
||||||
|
) {
|
||||||
|
s.renderGeneratorStatus(w, r, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
@@ -235,5 +359,21 @@ func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg strin
|
|||||||
FormQuality: form.Get("quality"),
|
FormQuality: form.Get("quality"),
|
||||||
FormFit: form.Get("fit"),
|
FormFit: form.Get("fit"),
|
||||||
FormTTL: form.Get("ttl"),
|
FormTTL: form.Get("ttl"),
|
||||||
})
|
}, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||||
|
// Build full URL (URL-encode the token for safety)
|
||||||
|
scheme := "https"
|
||||||
|
if s.config.Debug {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file extension for the trailing filename
|
||||||
|
ext := format
|
||||||
|
if ext == "" || ext == "orig" || ext == "auto" {
|
||||||
|
ext = "jpg" // Default extension
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFailedLoginLogsResolvedClientIP verifies the failed-login record
|
||||||
|
// carries the resolved client IP from the request context, not the raw
|
||||||
|
// proxy peer address.
|
||||||
|
func TestFailedLoginLogsResolvedClientIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||||
|
config: &config.Config{SigningKey: testSigningKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{loginKeyField: {"wrong-key"}}
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req = req.WithContext(clientip.WithClientIP(req.Context(), "203.0.113.7"))
|
||||||
|
|
||||||
|
h.handleLoginPost(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), `"remote_addr":"203.0.113.7"`) {
|
||||||
|
t.Errorf("failed-login log missing resolved client IP; got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSigningKey is a throwaway signing key for the CSRF flow tests. It
|
||||||
|
// seeds the session manager, the encrypted-URL generator, and the CSRF
|
||||||
|
// token key, exactly as the real signing key does in production.
|
||||||
|
const testSigningKey = "test-signing-key-0123456789abcdef"
|
||||||
|
|
||||||
|
// Form field names used in the CSRF flow tests.
|
||||||
|
const (
|
||||||
|
loginKeyField = "key"
|
||||||
|
// gorilla/csrf's default form field name, not a credential.
|
||||||
|
csrfTokenField = "gorilla.csrf.Token" //nolint:gosec // G101 false positive
|
||||||
|
)
|
||||||
|
|
||||||
|
// csrfFieldPattern extracts the token rendered by csrf.TemplateField into
|
||||||
|
// the form. The field name is gorilla/csrf's default.
|
||||||
|
var csrfFieldPattern = regexp.MustCompile(
|
||||||
|
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
||||||
|
|
||||||
|
// newCSRFTestRouter builds a router that mirrors the production wiring for
|
||||||
|
// the CSRF-protected UI routes (see server.SetupRoutes): the login and
|
||||||
|
// generator forms and their POST targets sit behind the real CSRF
|
||||||
|
// middleware. Requests are marked plaintext (Debug: true) so the flow runs
|
||||||
|
// over httptest's http transport without an https Referer.
|
||||||
|
func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
config: cfg,
|
||||||
|
sessMgr: sessMgr,
|
||||||
|
encGen: encGen,
|
||||||
|
csrfProtect: protect,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(h.CSRF())
|
||||||
|
r.Get("/", h.HandleRoot())
|
||||||
|
r.Post("/", h.HandleRoot())
|
||||||
|
r.Post("/generate", h.HandleGenerateURL())
|
||||||
|
})
|
||||||
|
|
||||||
|
return h, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfCredentials performs a GET that renders a form and returns the CSRF
|
||||||
|
// cookies the middleware set and the token embedded in the form. Passing
|
||||||
|
// the authenticated session cookie renders the generator form instead of
|
||||||
|
// the login form.
|
||||||
|
func csrfCredentials(
|
||||||
|
t *testing.T, srv http.Handler, reqCookies []*http.Cookie,
|
||||||
|
) ([]*http.Cookie, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
for _, c := range reqCookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := csrfFieldPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("no CSRF token field found in rendered form")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec.Result().Cookies(), match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// postForm submits form values with the given cookies and returns the
|
||||||
|
// recorder.
|
||||||
|
func postForm(
|
||||||
|
srv http.Handler, path string,
|
||||||
|
cookies []*http.Cookie, form url.Values,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost, path,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token
|
||||||
|
// is rejected. This is login CSRF: no session cookie exists yet, so the
|
||||||
|
// protection must rest on a token bound to a pre-session cookie.
|
||||||
|
func TestLoginPostRejectedWithoutToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", nil, url.Values{loginKeyField: {testSigningKey}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST / without token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPostRejectedWithForeignToken verifies that a token that does not
|
||||||
|
// match the request's CSRF cookie is rejected: a token minted for one
|
||||||
|
// cookie cannot authorize a request carrying a different cookie.
|
||||||
|
func TestLoginPostRejectedWithForeignToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookiesA, _ := csrfCredentials(t, srv, nil)
|
||||||
|
_, tokenB := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookiesA, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {tokenB},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST / with foreign token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPostAcceptedWithValidToken verifies that POST / with a matching
|
||||||
|
// cookie and token succeeds: the login is processed and a session is
|
||||||
|
// established (303 redirect).
|
||||||
|
func TestLoginPostAcceptedWithValidToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST / with valid token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
var authed bool
|
||||||
|
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName && c.Value != "" {
|
||||||
|
authed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authed {
|
||||||
|
t.Error("valid login did not set a session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectedWithoutToken verifies that POST /generate is
|
||||||
|
// rejected without a CSRF token even when the request carries a valid
|
||||||
|
// authenticated session. The session cookie is not sufficient; the policy
|
||||||
|
// requires a CSRF token on this cookie-authenticated form.
|
||||||
|
func TestGeneratePostRejectedWithoutToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate",
|
||||||
|
[]*http.Cookie{sessionCookie},
|
||||||
|
url.Values{"url": {"https://example.com/a.jpg"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST /generate without token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostAcceptedWithValidToken verifies that POST /generate
|
||||||
|
// succeeds with a valid session and a matching CSRF cookie and token.
|
||||||
|
func TestGeneratePostAcceptedWithValidToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
"url": {"https://example.com/a.jpg"},
|
||||||
|
"format": {"jpeg"},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /generate with valid token status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("generator response did not contain a generated URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSessionCookie creates an authenticated session cookie via the
|
||||||
|
// handler's session manager.
|
||||||
|
func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
err := h.sessMgr.CreateSession(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("session manager did not set a %q cookie", session.CookieName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generator form field names, and a value that is not a number.
|
||||||
|
const (
|
||||||
|
widthField = "width"
|
||||||
|
heightField = "height"
|
||||||
|
qualityField = "quality"
|
||||||
|
ttlField = "ttl"
|
||||||
|
fitField = "fit"
|
||||||
|
notANumber = "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generatePost submits the /generate form with a valid session and CSRF token
|
||||||
|
// plus the caller's extra fields, returning the recorder.
|
||||||
|
func generatePost(
|
||||||
|
t *testing.T, extra url.Values,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
}
|
||||||
|
maps.Copy(form, extra)
|
||||||
|
|
||||||
|
return postForm(srv, "/generate", cookies, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
|
||||||
|
// rejected with 400 naming the field rather than being coerced to 0 and
|
||||||
|
// minting a 0-width token.
|
||||||
|
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{"width": {"abc"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a token was generated for non-numeric width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
|
||||||
|
// MaxDimension is rejected at generation time so an unusable token cannot be
|
||||||
|
// minted.
|
||||||
|
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{"width": {"100000"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a token was generated for an over-limit width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsBadField verifies that each generator field whose
|
||||||
|
// value is not a number, out of range, or unrecognized is rejected with 400,
|
||||||
|
// mints no token, and is named in the error shown on the page.
|
||||||
|
func TestGeneratePostRejectsBadField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
field, value, wantError string
|
||||||
|
}{
|
||||||
|
{widthField, notANumber, "invalid width: not a number"},
|
||||||
|
{widthField, "-1", "width is negative"},
|
||||||
|
{widthField, "8193", "width is above 8192"},
|
||||||
|
{heightField, notANumber, "invalid height: not a number"},
|
||||||
|
{heightField, "8193", "height is above 8192"},
|
||||||
|
{qualityField, notANumber, "invalid quality: not a number"},
|
||||||
|
{qualityField, "0", "invalid quality: must be from 1 to 100"},
|
||||||
|
{qualityField, "101", "invalid quality: must be from 1 to 100"},
|
||||||
|
{ttlField, notANumber, "invalid ttl: not a number"},
|
||||||
|
{ttlField, "-1", "invalid ttl: must be from 0 to"},
|
||||||
|
{ttlField, "10000000000", "invalid ttl: must be from 0 to"},
|
||||||
|
{fitField, "bogus", "invalid fit mode: bogus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.field+"="+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{tt.field: {tt.value}})
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(body, "/v1/e/") {
|
||||||
|
t.Error("a token was generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, tt.wantError) {
|
||||||
|
t.Errorf("page does not show %q", tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
||||||
|
// takes the default, and that the largest accepted width, height, quality and
|
||||||
|
// ttl are accepted with an expiry still in the future.
|
||||||
|
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parsed, err := url.Parse(testSourceURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty form: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Quality != encurl.DefaultQuality {
|
||||||
|
t.Errorf("empty quality gave %d, want %d",
|
||||||
|
payload.Quality, encurl.DefaultQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
||||||
|
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
||||||
|
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
||||||
|
qualityField: {strconv.Itoa(maxQuality)},
|
||||||
|
ttlField: {strconv.Itoa(maxTTL)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("largest accepted values: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !expiresAt.After(time.Now()) {
|
||||||
|
t.Errorf("ttl %d gave expiry %v, want a time in the future",
|
||||||
|
maxTTL, expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,292 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatField is the generator form's format field name.
|
||||||
|
const formatField = "format"
|
||||||
|
|
||||||
|
// Markers telling the login page from the generator page.
|
||||||
|
const (
|
||||||
|
loginForm = `action="/"`
|
||||||
|
loginKeyInput = `name="key"`
|
||||||
|
generatorForm = `action="/generate"`
|
||||||
|
)
|
||||||
|
|
||||||
|
// generatedURLPattern extracts the path of the URL the generator page shows.
|
||||||
|
// The test router runs with debug on, so the URL starts with http, and its
|
||||||
|
// host is httptest's default request host.
|
||||||
|
var generatedURLPattern = regexp.MustCompile(
|
||||||
|
`value="http://example\.com(/v1/e/[^"]+)"`)
|
||||||
|
|
||||||
|
// findSessionCookie returns the session cookie rec sets, or nil if it sets
|
||||||
|
// none.
|
||||||
|
func findSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleRoot_NoSession_ShowsLoginForm verifies that GET / without a
|
||||||
|
// login session shows the login form.
|
||||||
|
func TestHandleRoot_NoSession_ShowsLoginForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
|
||||||
|
t.Errorf("page is not the login form: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPost_WrongKey_ShowsErrorWithoutSession verifies that a wrong key
|
||||||
|
// shows the login form again with an error, and sets no session cookie.
|
||||||
|
func TestLoginPost_WrongKey_ShowsErrorWithoutSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {"wrong-signing-key-fedcba9876543210"},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
|
||||||
|
t.Errorf("page is not the login form: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, "Invalid signing key") {
|
||||||
|
t.Error("login form does not show the error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := findSessionCookie(rec); c != nil {
|
||||||
|
t.Errorf("wrong key set a session cookie: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPost_RightKey_SetsSessionCookie verifies that the right key answers
|
||||||
|
// 303 to / with a session cookie marked Secure, HttpOnly and SameSite=Strict,
|
||||||
|
// and that GET / with that cookie shows the generator page.
|
||||||
|
func TestLoginPost_RightKey_SetsSessionCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||||
|
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCookie := findSessionCookie(rec)
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatal("right key set no session cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Set-Cookie: %s", sessionCookie)
|
||||||
|
|
||||||
|
if !sessionCookie.Secure {
|
||||||
|
t.Error("session cookie is not Secure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sessionCookie.HttpOnly {
|
||||||
|
t.Error("session cookie is not HttpOnly")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Errorf("session cookie SameSite = %v, want Strict", sessionCookie.SameSite)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(sessionCookie)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(rec.Body.String(), generatorForm) {
|
||||||
|
t.Errorf("GET / with the session cookie: status = %d, "+
|
||||||
|
"want %d and the generator page", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleLogout_ClearsSessionCookie verifies that GET /logout answers 303
|
||||||
|
// to / and replaces the session cookie with an empty one sent with
|
||||||
|
// Max-Age=0, which makes the browser delete it.
|
||||||
|
func TestHandleLogout_ClearsSessionCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, _ := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/logout", nil)
|
||||||
|
req.AddCookie(newSessionCookie(t, h))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleLogout().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||||
|
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Set-Cookie: %s", rec.Header().Get("Set-Cookie"))
|
||||||
|
|
||||||
|
sessionCookie := findSessionCookie(rec)
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatal("logout did not set the session cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie.Value != "" {
|
||||||
|
t.Errorf("session cookie value = %q, want empty", sessionCookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// net/http reads a Max-Age=0 attribute back as MaxAge -1.
|
||||||
|
if sessionCookie.MaxAge != -1 {
|
||||||
|
t.Errorf("session cookie MaxAge = %d, want -1 (Max-Age=0)",
|
||||||
|
sessionCookie.MaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
||||||
|
// with a valid CSRF token but no login session answers 303 to / and makes no
|
||||||
|
// URL.
|
||||||
|
func TestGeneratePost_NoSession_RedirectsToLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||||
|
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a URL was made without a login session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_URLServesImage verifies that the URL the generator page
|
||||||
|
// makes is served by /v1/e/. The image route runs on handlers of its own,
|
||||||
|
// made with the same signing key.
|
||||||
|
func TestGeneratePost_URLServesImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{
|
||||||
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
|
widthField: {"50"},
|
||||||
|
heightField: {"50"},
|
||||||
|
formatField: {string(imgcache.FormatJPEG)},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("generated URL path: %s", match[1])
|
||||||
|
|
||||||
|
imageRec := httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
requireServedPhoto(t, imageRec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePost_URLWithTTLExpires verifies that a URL the generator page
|
||||||
|
// makes with a ttl of one second is served by /v1/e/ at once and answers 410
|
||||||
|
// once the ttl has passed.
|
||||||
|
func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{
|
||||||
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
|
widthField: {"50"},
|
||||||
|
heightField: {"50"},
|
||||||
|
formatField: {string(imgcache.FormatJPEG)},
|
||||||
|
ttlField: {"1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
imageRec := httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
requireServedPhoto(t, imageRec)
|
||||||
|
|
||||||
|
// The URL keeps the time it expires in whole seconds and is served
|
||||||
|
// through the whole of that second, so a ttl of one second has passed
|
||||||
|
// for certain two seconds after the URL was made.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
imageRec = httptest.NewRecorder()
|
||||||
|
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, match[1], nil))
|
||||||
|
|
||||||
|
t.Logf("GET %s after the ttl: %d %q", match[1], imageRec.Code, imageRec.Body)
|
||||||
|
|
||||||
|
if imageRec.Code != http.StatusGone {
|
||||||
|
t.Errorf("status after the ttl = %d, want %d",
|
||||||
|
imageRec.Code, http.StatusGone)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxFormBytes bounds the request body accepted on the HTML form POST
|
||||||
|
// routes (POST / and POST /generate). The forms carry a handful of short
|
||||||
|
// fields, so 1 MiB is generous while making the bound explicit rather than
|
||||||
|
// resting on ParseForm's incidental 10 MB cap.
|
||||||
|
const MaxFormBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
// LimitBody returns middleware that caps the request body on POST requests
|
||||||
|
// at maxBytes and rejects an oversized body with 413 Request Entity Too
|
||||||
|
// Large.
|
||||||
|
//
|
||||||
|
// It parses the form here, before the CSRF middleware reads the token from
|
||||||
|
// it. The CSRF middleware reads the token with PostFormValue, which
|
||||||
|
// swallows a parse error, so if the body were only capped there an
|
||||||
|
// oversized body would read as a missing token and be refused as 403. By
|
||||||
|
// parsing under the cap first, an oversized body is refused as 413. A
|
||||||
|
// successful parse is cached on the request, so the CSRF check and the
|
||||||
|
// handler reuse it rather than reading the body again.
|
||||||
|
func (s *Handlers) LimitBody(maxBytes int64) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
http.Error(w, "Request body too large",
|
||||||
|
http.StatusRequestEntityTooLarge)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Form field names and a throwaway source image URL for the body-limit
|
||||||
|
// tests.
|
||||||
|
const (
|
||||||
|
sourceURLField = "url"
|
||||||
|
testSourceURL = "https://example.com/a.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newBodyLimitTestRouter mirrors the production wiring for the form POST
|
||||||
|
// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF
|
||||||
|
// middleware, which sits in front of the handlers. maxBytes is the body
|
||||||
|
// cap under test, so a test can trip the limit with a small body.
|
||||||
|
func newBodyLimitTestRouter(
|
||||||
|
t *testing.T, maxBytes int64,
|
||||||
|
) (*Handlers, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
config: cfg,
|
||||||
|
sessMgr: sessMgr,
|
||||||
|
encGen: encGen,
|
||||||
|
csrfProtect: protect,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(h.LimitBody(maxBytes))
|
||||||
|
r.Use(h.CSRF())
|
||||||
|
r.Get("/", h.HandleRoot())
|
||||||
|
r.Post("/", h.HandleRoot())
|
||||||
|
r.Post("/generate", h.HandleGenerateURL())
|
||||||
|
})
|
||||||
|
|
||||||
|
return h, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an
|
||||||
|
// oversized POST / carrying an otherwise valid CSRF cookie and token must
|
||||||
|
// be rejected with 413. If the body limit ran after CSRF, the truncated
|
||||||
|
// body would read as a missing token and return 403; if it ran after the
|
||||||
|
// handler, a valid token would return 303. Getting 413 proves the limit
|
||||||
|
// fires before CSRF parses the form.
|
||||||
|
func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newBodyLimitTestRouter(t, 16)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversized POST / status = %d, want %d",
|
||||||
|
rec.Code, http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for
|
||||||
|
// POST /generate, which also parses a form behind CSRF.
|
||||||
|
func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newBodyLimitTestRouter(t, 16)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversized POST /generate status = %d, want %d",
|
||||||
|
rec.Code, http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a
|
||||||
|
// normal request: under the production cap, a valid login still parses and
|
||||||
|
// establishes a session (303). This guards against the body limit
|
||||||
|
// consuming or corrupting the form the CSRF check and handler depend on.
|
||||||
|
func TestWithinLimitLoginPostSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, nil)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/", cookies, url.Values{
|
||||||
|
loginKeyField: {testSigningKey},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("within-limit POST / status = %d, want %d",
|
||||||
|
rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
var authed bool
|
||||||
|
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName && c.Value != "" {
|
||||||
|
authed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authed {
|
||||||
|
t.Error("within-limit valid login did not set a session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithinLimitGeneratePostSucceeds is the same non-regression check for
|
||||||
|
// POST /generate.
|
||||||
|
func TestWithinLimitGeneratePostSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
"format": {"jpeg"},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("within-limit POST /generate status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("within-limit generate response did not contain a generated URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,65 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// csrfKeySalt provides domain separation for the CSRF authentication key,
|
||||||
|
// derived from the signing key so tokens survive restarts without extra
|
||||||
|
// configuration and never reuse the session or encrypted-URL key material.
|
||||||
|
const csrfKeySalt = "pixa-csrf-v1"
|
||||||
|
|
||||||
|
// newCSRFProtect builds the CSRF-protection middleware for the
|
||||||
|
// state-mutating HTML form routes. The token lives in its own cookie,
|
||||||
|
// independent of the session cookie, so it also protects the login POST
|
||||||
|
// where no session exists yet (login CSRF).
|
||||||
|
//
|
||||||
|
// When plaintext is true (local HTTP development), requests are marked
|
||||||
|
// plaintext so the library neither demands an https Referer nor sets a
|
||||||
|
// Secure cookie the browser would withhold over http. In production the
|
||||||
|
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
||||||
|
// the library enforces its https Referer origin check.
|
||||||
|
func newCSRFProtect(
|
||||||
|
signingKey string, plaintext bool,
|
||||||
|
) (func(http.Handler) http.Handler, error) {
|
||||||
|
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
protect := csrf.Protect(
|
||||||
|
key[:],
|
||||||
|
csrf.Path("/"),
|
||||||
|
csrf.Secure(!plaintext),
|
||||||
|
csrf.SameSite(csrf.SameSiteStrictMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !plaintext {
|
||||||
|
return protect, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
protected := protect(next)
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
||||||
|
})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF returns the CSRF-protection middleware for the login and generator
|
||||||
|
// form routes.
|
||||||
|
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
|
||||||
|
return s.csrfProtect
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfField returns the hidden form input carrying the CSRF token for the
|
||||||
|
// given request, to be embedded verbatim in a rendered form.
|
||||||
|
func csrfField(r *http.Request) template.HTML {
|
||||||
|
return csrf.TemplateField(r)
|
||||||
|
}
|
||||||
@@ -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,131 @@
|
|||||||
|
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/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: 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.
|
||||||
|
// 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/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ import (
|
|||||||
"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"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
@@ -21,10 +23,16 @@ import (
|
|||||||
// Params defines dependencies for Handlers.
|
// Params defines dependencies for Handlers.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
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.
|
||||||
@@ -33,57 +41,109 @@ 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
|
||||||
|
|
||||||
|
// 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.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
|
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s := &Handlers{
|
s := &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
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
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return s.initImageService()
|
return s.initImageService()
|
||||||
},
|
},
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
if s.imgCache == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.imgCache.StopEviction(ctx)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initImageService initializes the image cache and service.
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
func (s *Handlers) initImageService() error {
|
// ends, and returns how many images were still being processed then.
|
||||||
// Create the cache
|
func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
||||||
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
return s.imgSvc.WaitForProcessing(ctx)
|
||||||
StateDir: s.config.StateDir,
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
CacheTTL: imgcache.DefaultCacheTTL,
|
CacheTTL: imgcache.DefaultCacheTTL,
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||||
})
|
MaxBytes: cfg.CacheMaxBytes,
|
||||||
|
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
||||||
|
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
|
||||||
|
Logger: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initImageService initializes the image cache and service.
|
||||||
|
func (s *Handlers) initImageService() error {
|
||||||
|
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.imgCache = cache
|
s.imgCache = cache
|
||||||
|
|
||||||
|
// Background eviction: startup reconciliation, then periodic and
|
||||||
|
// write-pressure passes. No-op when the disk cache is disabled.
|
||||||
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := imgcache.DefaultFetcherConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
|
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
||||||
|
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
||||||
|
|
||||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the service
|
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
||||||
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||||
|
|
||||||
|
// 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,
|
||||||
Whitelist: s.config.WhitelistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
|
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
||||||
Logger: s.log,
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,11 +153,13 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgSvc = svc
|
s.imgSvc = svc
|
||||||
s.log.Info("image service initialized")
|
s.log.Info("image service initialized")
|
||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load time)
|
// Initialize session manager (signing key is validated at config load
|
||||||
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||||
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.sessMgr = sessMgr
|
s.sessMgr = sessMgr
|
||||||
|
|
||||||
// Initialize encrypted URL generator
|
// Initialize encrypted URL generator
|
||||||
@@ -105,6 +167,7 @@ func (s *Handlers) initImageService() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.encGen = encGen
|
s.encGen = encGen
|
||||||
|
|
||||||
s.log.Info("session manager and URL generator initialized")
|
s.log.Info("session manager and URL generator initialized")
|
||||||
@@ -112,9 +175,10 @@ func (s *Handlers) initImageService() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status int) {
|
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
err := json.NewEncoder(w).Encode(data)
|
err := json.NewEncoder(w).Encode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,7 +188,7 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status i
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
||||||
s.respondJSON(w, map[string]interface{}{
|
s.respondJSON(w, map[string]any{
|
||||||
"error": message,
|
"error": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
SigningKey: "test-signing-key-must-be-32-chars",
|
SigningKey: "test-signing-key-must-be-32-chars",
|
||||||
Whitelist: []string{goodHost},
|
Allowlist: []string{goodHost},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -82,7 +84,8 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
t.Fatalf("failed to open test db: %v", err)
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +96,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +121,18 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
return &mockFetcher{fs: fs}
|
return &mockFetcher{fs: fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchResult, error) {
|
func (f *mockFetcher) Fetch(
|
||||||
|
_ context.Context, url string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
// Remove https:// prefix
|
// Remove https:// prefix
|
||||||
path := url[8:] // Remove "https://"
|
path := url[8:] // Remove "https://"
|
||||||
|
|
||||||
data, err := fs.ReadFile(f.fs, path)
|
data, err := fs.ReadFile(f.fs, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, imgcache.ErrUpstreamError
|
return nil, httpfetcher.ErrUpstreamError
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.FetchResult{
|
return &httpfetcher.FetchResult{
|
||||||
Content: io.NopCloser(bytes.NewReader(data)),
|
Content: io.NopCloser(bytes.NewReader(data)),
|
||||||
ContentLength: int64(len(data)),
|
ContentLength: int64(len(data)),
|
||||||
ContentType: "image/jpeg",
|
ContentType: "image/jpeg",
|
||||||
@@ -133,13 +140,16 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
// Create a chi router to properly handle wildcards
|
// Create a chi router to properly handle wildcards
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Head("/v1/image/*", fix.handler.HandleImage())
|
r.Head("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodHead, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodHead,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
@@ -166,13 +176,16 @@ func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
// First request to get the ETag
|
// First request to get the ETag
|
||||||
req1 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec1 := httptest.NewRecorder()
|
rec1 := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec1, req1)
|
r.ServeHTTP(rec1, req1)
|
||||||
@@ -187,15 +200,18 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second request with If-None-Match header
|
// Second request with If-None-Match header
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
req2.Header.Set("If-None-Match", etag)
|
req2.Header.Set("If-None-Match", etag)
|
||||||
|
|
||||||
rec2 := httptest.NewRecorder()
|
rec2 := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec2, req2)
|
r.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
// Should return 304 Not Modified
|
// Should return 304 Not Modified
|
||||||
if rec2.Code != http.StatusNotModified {
|
if rec2.Code != http.StatusNotModified {
|
||||||
t.Errorf("Conditional request status = %d, want %d", rec2.Code, http.StatusNotModified)
|
t.Errorf("Conditional request status = %d, want %d",
|
||||||
|
rec2.Code, http.StatusNotModified)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body should be empty for 304 response
|
// Body should be empty for 304 response
|
||||||
@@ -205,21 +221,26 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
// Request with non-matching ETag
|
// Request with non-matching ETag
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
req.Header.Set("If-None-Match", `"different-etag"`)
|
req.Header.Set("If-None-Match", `"different-etag"`)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// Should return 200 OK with full response
|
// Should return 200 OK with full response
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("Request with non-matching ETag status = %d, want %d", rec.Code, http.StatusOK)
|
t.Errorf("Request with non-matching ETag status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body should not be empty
|
// Body should not be empty
|
||||||
@@ -229,12 +250,15 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ETagHeader(t *testing.T) {
|
func TestHandleImage_ETagHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
@@ -253,3 +277,120 @@ func TestHandleImage_ETagHeader(t *testing.T) {
|
|||||||
t.Errorf("ETag should be quoted, got %q", etag)
|
t.Errorf("ETag should be quoted, got %q", etag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image
|
||||||
|
// route rejects an unrecognized fit mode with 400.
|
||||||
|
func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
status := getImage(t, fix,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus")
|
||||||
|
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidQuery_Returns400 verifies that the plain image route
|
||||||
|
// answers a q that is not a whole number from 1 to 100, an empty one
|
||||||
|
// included, with 400 naming q and the value, a parameter given more than once
|
||||||
|
// with 400 naming it, and a query string that cannot be decoded with 400
|
||||||
|
// showing it, instead of serving the image at the default quality 85 or at
|
||||||
|
// the first value given.
|
||||||
|
func TestHandleImage_InvalidQuery_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
query, wantError string
|
||||||
|
}{
|
||||||
|
{"q=banana", `invalid q: not a number, got "banana"`},
|
||||||
|
{"q=0", `invalid q: must be from 1 to 100, got "0"`},
|
||||||
|
{"q=101", `invalid q: must be from 1 to 100, got "101"`},
|
||||||
|
{"q=", `invalid q: not a number, got ""`},
|
||||||
|
{"q=80&q=500", `invalid q: given more than once`},
|
||||||
|
{"q=80&q=", `invalid q: given more than once`},
|
||||||
|
{"fit=cover&fit=contain", `invalid fit: given more than once`},
|
||||||
|
{"q=80%", `invalid query string "q=80%": invalid URL escape "%"`},
|
||||||
|
{
|
||||||
|
"q=50;fit=contain",
|
||||||
|
`invalid query string "q=50;fit=contain": ` +
|
||||||
|
`invalid semicolon separator in query`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.query, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Error != tt.wantError {
|
||||||
|
t.Errorf("error = %q, want %q", body.Error, tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_EmptyFit_Returns400 verifies that the plain image route
|
||||||
|
// answers a fit that is in the URL but empty with 400 naming fit, instead of
|
||||||
|
// serving the image as cover. Only a fit missing from the URL means cover.
|
||||||
|
func TestHandleImage_EmptyFit_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantError := `invalid fit: not a fit mode, got ""`
|
||||||
|
if body.Error != wantError {
|
||||||
|
t.Errorf("error = %q, want %q", body.Error, wantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
-64
@@ -2,12 +2,18 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,8 +21,70 @@ import (
|
|||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, ok := s.parseImageRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate signature if required
|
||||||
|
err := s.imgSvc.ValidateRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("signature validation failed",
|
||||||
|
"host", req.SourceHost,
|
||||||
|
"path", req.SourcePath,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signature covers the format auto, not the format chosen
|
||||||
|
if !s.chooseAutoFormat(w, r, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache key for logging
|
||||||
|
cacheKey := imgcache.CacheKey(req)
|
||||||
|
|
||||||
|
// Get the image (from cache or fetch/process)
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
resp, err := s.imgSvc.Get(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
s.respondImageError(w, req, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRobotsTxt serves robots.txt to prevent search engine crawling.
|
||||||
|
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
||||||
|
robotsTxt := []byte("User-agent: *\nDisallow: /\n")
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(robotsTxt)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(robotsTxt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageRequest parses the wildcard path and query parameters into
|
||||||
|
// an ImageRequest. On invalid input it writes an error response and
|
||||||
|
// returns false.
|
||||||
|
func (s *Handlers) parseImageRequest(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) (*imgcache.ImageRequest, bool) {
|
||||||
// Get the wildcard path from chi
|
// Get the wildcard path from chi
|
||||||
pathParam := chi.URLParam(r, "*")
|
pathParam := chi.URLParam(r, "*")
|
||||||
|
|
||||||
@@ -29,41 +97,72 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to ImageRequest
|
// Convert to ImageRequest
|
||||||
req := parsed.ToImageRequest()
|
req := parsed.ToImageRequest()
|
||||||
|
|
||||||
// Parse signature params from query string
|
// Parse signature params from query string. r.URL.Query() would silently
|
||||||
query := r.URL.Query()
|
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
||||||
|
// at 85; a query string that cannot be decoded is refused instead. A
|
||||||
|
// parameter given more than once is refused too, as only its first value
|
||||||
|
// would be read.
|
||||||
|
query, err := url.ParseQuery(r.URL.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
||||||
|
r.URL.RawQuery, err), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, values := range query {
|
||||||
|
if len(values) > 1 {
|
||||||
|
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
||||||
|
name), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.Signature = query.Get("sig")
|
req.Signature = query.Get("sig")
|
||||||
|
|
||||||
if expStr := query.Get("exp"); expStr != "" {
|
req.Expires, err = parseExpires(query)
|
||||||
if exp, err := strconv.ParseInt(expStr, 10, 64); err == nil {
|
if err != nil {
|
||||||
req.Expires = time.Unix(exp, 0)
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
}
|
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse optional quality and fit params
|
// Parse optional quality and fit params. Only a q missing from the URL is
|
||||||
if qStr := query.Get("q"); qStr != "" {
|
// 85. A q in the URL that is not a whole number from 1 to 100, an empty
|
||||||
if q, err := strconv.Atoi(qStr); err == nil && q > 0 && q <= 100 {
|
// one included, is refused, checked as the generator checks its quality
|
||||||
req.Quality = q
|
// field; that check alone would take an empty q as missing.
|
||||||
}
|
qStr := query.Get("q")
|
||||||
|
if query.Has("q") && qStr == "" {
|
||||||
|
s.respondError(w, `invalid q: not a number, got ""`,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if fit := query.Get("fit"); fit != "" {
|
req.Quality, err = parseFormInt(query, "q",
|
||||||
req.FitMode = imgcache.FitMode(fit)
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
if err := imgcache.ValidateFitMode(req.FitMode); err != nil {
|
if err != nil {
|
||||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return nil, false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default quality if not set
|
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
||||||
if req.Quality == 0 {
|
// fit mode is refused by the fit-mode check below; that check would take an
|
||||||
req.Quality = 85
|
// empty fit as missing, so an empty one is refused here.
|
||||||
|
req.FitMode = imgcache.FitMode(query.Get("fit"))
|
||||||
|
if query.Has("fit") && req.FitMode == "" {
|
||||||
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
@@ -71,25 +170,45 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
req.FitMode = imgcache.FitCover
|
req.FitMode = imgcache.FitCover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate signature if required
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||||
if err := s.imgSvc.ValidateRequest(req); err != nil {
|
// route. Dimensions are already bounded by the path parser above; this
|
||||||
s.log.Warn("signature validation failed",
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||||
"host", req.SourceHost,
|
// reach the processor as a 500.
|
||||||
"path", req.SourcePath,
|
err = imgcache.ValidateImageRequest(req)
|
||||||
"error", err,
|
if err != nil {
|
||||||
)
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cache key for logging
|
return req, true
|
||||||
cacheKey := imgcache.CacheKey(req)
|
}
|
||||||
|
|
||||||
// Get the image (from cache or fetch/process)
|
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
||||||
startTime := time.Now()
|
// missing from the URL gives the zero time, which the signature check takes
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
// as no expiration. An exp in the URL that is not a whole number, an empty
|
||||||
|
// one included, is an error naming exp and the value.
|
||||||
|
func parseExpires(query url.Values) (time.Time, error) {
|
||||||
|
if !query.Has("exp") {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expStr := query.Get("exp")
|
||||||
|
|
||||||
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("%w exp: not a number, got %q",
|
||||||
|
errInvalidFormField, expStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Unix(exp, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
|
func (s *Handlers) respondImageError(
|
||||||
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
|
) {
|
||||||
s.log.Error("failed to get image",
|
s.log.Error("failed to get image",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
@@ -97,46 +216,104 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Check for specific error types
|
// Check for specific error types
|
||||||
if errors.Is(err, imgcache.ErrSSRFBlocked) {
|
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, imgcache.ErrUpstreamError) {
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
||||||
|
errors.Is(err, imageprocessor.ErrTooManyImages) {
|
||||||
|
s.respondError(w, "server busy, try again later",
|
||||||
|
http.StatusServiceUnavailable)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Content.Close() }()
|
|
||||||
|
|
||||||
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheControl returns the Cache-Control header for an image served through a
|
||||||
|
// URL that expires at expires, or never when expires is the zero time. A cache
|
||||||
|
// may keep the image for a year, but not past the URL's expiry, after which
|
||||||
|
// pixa refuses the URL. The seconds left are rounded down and never negative.
|
||||||
|
// immutable only stops revalidation while the image is fresh, so it also ends
|
||||||
|
// at the expiry.
|
||||||
|
func cacheControl(expires time.Time) string {
|
||||||
|
const oneYear = 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
maxAge := oneYear
|
||||||
|
|
||||||
|
if !expires.IsZero() {
|
||||||
|
maxAge = min(maxAge, max(time.Until(expires), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
|
||||||
|
// names a host on referer_blocklist, and reports whether it answered. A request
|
||||||
|
// with no Referer, or one that does not parse as a URL with a host, is not
|
||||||
|
// refused.
|
||||||
|
func (s *Handlers) refuseBlockedReferer(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) bool {
|
||||||
|
referer, err := url.Parse(r.Referer())
|
||||||
|
if err != nil || !s.refererBlocklist.IsAllowed(referer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondError(w, "referer blocked", http.StatusForbidden)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// notModified sets the ETag header to etag and, when the request's
|
||||||
|
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
|
||||||
|
// answered. An empty etag sets no header and never answers.
|
||||||
|
func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
||||||
|
if etag == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
|
||||||
|
if r.Header.Get("If-None-Match") != etag {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeImageResponse writes headers and streams the image content,
|
||||||
|
// handling conditional and HEAD requests.
|
||||||
|
func (s *Handlers) writeImageResponse(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
||||||
|
cacheKey imgcache.VariantKey, startTime time.Time,
|
||||||
|
) {
|
||||||
// Set response headers
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 {
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if resp.ETag != "" {
|
if notModified(w, r, resp.ETag) {
|
||||||
w.Header().Set("ETag", resp.ETag)
|
|
||||||
|
|
||||||
// Check for conditional request (If-None-Match)
|
|
||||||
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
|
|
||||||
if ifNoneMatch == resp.ETag {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle HEAD request - return headers only
|
// Handle HEAD request - return headers only
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
@@ -158,6 +335,7 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
// Log cache status and timing after serving
|
// Log cache status and timing after serving
|
||||||
duration := time.Since(startTime)
|
duration := time.Since(startTime)
|
||||||
s.log.Info("image served",
|
s.log.Info("image served",
|
||||||
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
"cache_key", cacheKey,
|
"cache_key", cacheKey,
|
||||||
"cache_status", resp.CacheStatus,
|
"cache_status", resp.CacheStatus,
|
||||||
"duration_ms", duration.Milliseconds(),
|
"duration_ms", duration.Milliseconds(),
|
||||||
@@ -165,17 +343,4 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
"served_bytes", servedBytes,
|
"served_bytes", servedBytes,
|
||||||
"fetched_bytes", resp.FetchedBytes,
|
"fetched_bytes", resp.FetchedBytes,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleRobotsTxt serves robots.txt to prevent search engine crawling.
|
|
||||||
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|
||||||
robotsTxt := []byte("User-agent: *\nDisallow: /\n")
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(robotsTxt)))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(robotsTxt)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// photoPath is the path of the JPEG that newSignedHostServer serves.
|
||||||
|
const photoPath = "/images/photo.jpg"
|
||||||
|
|
||||||
|
// newSignedHostServer returns a router for both image routes, and the Handlers
|
||||||
|
// behind it, whose fetcher serves a JPEG at photoPath on signedHost. signedHost
|
||||||
|
// is not on the allowlist, so a /v1/image/ URL for it is served only with a
|
||||||
|
// valid signature. The handlers and the image service log to log.
|
||||||
|
func newSignedHostServer(
|
||||||
|
t *testing.T, log *slog.Logger,
|
||||||
|
) (*Handlers, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||||
|
|
||||||
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
|
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
||||||
|
}),
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: log,
|
||||||
|
imgSvc: svc,
|
||||||
|
encGen: encGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return h, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMaxAge sends a GET for target to srv, requires a 200, and returns the
|
||||||
|
// max-age of the response's Cache-Control header, which must read
|
||||||
|
// "public, max-age=<seconds>, immutable".
|
||||||
|
func getMaxAge(t *testing.T, srv http.Handler, target string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
header := rec.Header().Get("Cache-Control")
|
||||||
|
t.Logf("GET %s: %d, Cache-Control: %s", target, rec.Code, header)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, hasPrefix := strings.CutPrefix(header, "public, max-age=")
|
||||||
|
value, hasSuffix := strings.CutSuffix(value, ", immutable")
|
||||||
|
|
||||||
|
maxAge, err := strconv.Atoi(value)
|
||||||
|
if !hasPrefix || !hasSuffix || err != nil {
|
||||||
|
t.Fatalf("Cache-Control = %q, want public, max-age=<seconds>, immutable",
|
||||||
|
header)
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxAge
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SignedURL_MaxAgeEndsAtExp verifies that an image served
|
||||||
|
// through a signed URL expiring in 60 seconds may be cached for at most those
|
||||||
|
// 60 seconds. The lower bound of 50 shows the max-age is the time left, not 0.
|
||||||
|
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
}, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAge := getMaxAge(t, srv, signedURL)
|
||||||
|
if maxAge < 50 || maxAge > 60 {
|
||||||
|
t.Errorf("max-age = %d, want 50 to 60", maxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_AllowlistedHost_MaxAge verifies the max-age of an image from
|
||||||
|
// an allowlisted host, which is served without checking sig or exp. A URL with
|
||||||
|
// no exp may be cached for a year. A URL whose exp has passed is the one request
|
||||||
|
// that reaches the header after its expiry, and must get 0, never less.
|
||||||
|
func TestHandleImage_AllowlistedHost_MaxAge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
pastExp := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantMaxAge int
|
||||||
|
}{
|
||||||
|
{"no exp", "", 31536000},
|
||||||
|
{"exp already past", "?exp=" + pastExp, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
maxAge := getMaxAge(t, r,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg"+tt.query)
|
||||||
|
if maxAge != tt.wantMaxAge {
|
||||||
|
t.Errorf("max-age = %d, want %d", maxAge, tt.wantMaxAge)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_MaxAge verifies that an image served through an encrypted
|
||||||
|
// URL with a 60 second TTL may be cached for at most those 60 seconds, that one
|
||||||
|
// with a two-year TTL may be cached for a year, and that one made without a
|
||||||
|
// TTL, which never expires, may be cached for a year.
|
||||||
|
func TestHandleImageEnc_MaxAge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expiresAt int64
|
||||||
|
wantAtLeast int
|
||||||
|
wantAtMost int
|
||||||
|
}{
|
||||||
|
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
|
||||||
|
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
|
||||||
|
{"no TTL", 0, 31536000, 31536000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
token, err := h.encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
ExpiresAt: tt.expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAge := getMaxAge(t, srv, "/v1/e/"+token+"/img.jpg")
|
||||||
|
if maxAge < tt.wantAtLeast || maxAge > tt.wantAtMost {
|
||||||
|
t.Errorf("max-age = %d, want %d to %d",
|
||||||
|
maxAge, tt.wantAtLeast, tt.wantAtMost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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(),
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
||||||
|
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
||||||
|
// request that passes the signature check gets 502 from the failed fetch, and
|
||||||
|
// one that fails the check gets 401.
|
||||||
|
const signedHost = "signed.example.com"
|
||||||
|
|
||||||
|
// getImage sends a GET for target to the image route of fix and returns the
|
||||||
|
// response status.
|
||||||
|
func getImage(t *testing.T, fix *testFixtures, target string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET %s: %d", target, rec.Code)
|
||||||
|
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
|
||||||
|
// and fit cover, the values the route uses when a URL has no q or fit, and
|
||||||
|
// sends that signature with each q and fit below.
|
||||||
|
func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
|
sig := signer.Sign(&signature.Request{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: "/images/photo.jpg",
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: string(imgcache.FormatJPEG),
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
||||||
|
signedHost, sig, expires.Unix())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"no q or fit", "", http.StatusBadGateway},
|
||||||
|
{"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway},
|
||||||
|
{"replayed with q=40", "&q=40", http.StatusUnauthorized},
|
||||||
|
{"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
status := getImage(t, setupTestHandler(t), signedURL+tt.query)
|
||||||
|
if status != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
||||||
|
// service's signed-URL generator to the route.
|
||||||
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
quality int
|
||||||
|
fitMode imgcache.FitMode
|
||||||
|
}{
|
||||||
|
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
||||||
|
{"quality and fit unset", 0, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: "/images/photo.jpg",
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
Quality: tt.quality,
|
||||||
|
FitMode: tt.fitMode,
|
||||||
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getImage(t, fix, signedURL)
|
||||||
|
if status != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is
|
||||||
|
// not a whole number, and one whose exp is empty. Each is refused with 400
|
||||||
|
// naming exp and the value, not with the 401 a URL without exp still gets.
|
||||||
|
func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
query string
|
||||||
|
wantStatus int
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{"sig=x&exp=banana", http.StatusBadRequest,
|
||||||
|
`invalid exp: not a number, got "banana"`},
|
||||||
|
{"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`},
|
||||||
|
{"sig=x", http.StatusUnauthorized, "unauthorized"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.query, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus || body.Error != tt.wantError {
|
||||||
|
t.Errorf("got %d %q, want %d %q",
|
||||||
|
rec.Code, body.Error, tt.wantStatus, tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,45 +9,31 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"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/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted image URLs.
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
||||||
// The trailing path (e.g., /img.jpg) is ignored but helps browsers identify the content type.
|
// image URLs. The trailing path (e.g., /img.jpg) is ignored but helps
|
||||||
|
// browsers identify the content type.
|
||||||
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.refuseBlockedReferer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Extract token from URL
|
req, ok := s.parseImageEncRequest(w, r)
|
||||||
token := chi.URLParam(r, "token")
|
if !ok || !s.chooseAutoFormat(w, r, req) {
|
||||||
if token == "" {
|
|
||||||
s.respondError(w, "missing token", http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and validate the payload
|
|
||||||
payload, err := s.encGen.Parse(token)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, encurl.ErrExpired) {
|
|
||||||
s.log.Debug("encrypted URL expired", "error", err)
|
|
||||||
s.respondError(w, "URL has expired", http.StatusGone)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Debug("failed to decrypt URL", "error", err)
|
|
||||||
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert payload to ImageRequest
|
|
||||||
req := payload.ToImageRequest()
|
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
s.log.Debug("encrypted image request",
|
s.log.Debug("encrypted image request",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
@@ -56,25 +42,39 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
"format", req.Format,
|
"format", req.Format,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
|
// Fetch and process the image (no signature validation
|
||||||
|
// needed - encrypted URL is trusted)
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
resp, err := s.imgSvc.Get(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.handleImageError(w, err)
|
s.handleImageError(w, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Content.Close() }()
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// Set response headers
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 {
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache headers - encrypted URLs can be cached since they're immutable
|
// Cache headers: max-age ends at the URL's expiry
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
|
if notModified(w, r, resp.ETag) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A HEAD request gets the headers only
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Stream the response
|
// Stream the response
|
||||||
written, err := io.Copy(w, resp.Content)
|
written, err := io.Copy(w, resp.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,6 +86,7 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// Log completion
|
// Log completion
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
s.log.Info("image served",
|
s.log.Info("image served",
|
||||||
|
"request_id", middleware.GetReqID(ctx),
|
||||||
"cache_key", imgcache.CacheKey(req),
|
"cache_key", imgcache.CacheKey(req),
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
@@ -97,15 +98,69 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
||||||
|
// ImageRequest and checks it. On a token that is missing, does not decrypt,
|
||||||
|
// has expired or asks for something not valid, it writes an error response
|
||||||
|
// and returns false.
|
||||||
|
func (s *Handlers) parseImageEncRequest(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) (*imgcache.ImageRequest, bool) {
|
||||||
|
// Extract token from URL
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
if token == "" {
|
||||||
|
s.respondError(w, "missing token", http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and validate the payload
|
||||||
|
payload, err := s.encGen.Parse(token)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, encurl.ErrExpired) {
|
||||||
|
s.log.Debug("encrypted URL expired", "error", err)
|
||||||
|
s.respondError(w, "URL has expired", http.StatusGone)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Debug("failed to decrypt URL", "error", err)
|
||||||
|
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert payload to ImageRequest
|
||||||
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
|
// Apply the same dimension and fit-mode bounds as the plain image
|
||||||
|
// route: a sealed payload is trusted for its origin, not for staying
|
||||||
|
// within limits, so an over-limit size or unknown fit mode is a 400
|
||||||
|
// here rather than an out-of-memory or a 500 from the processor.
|
||||||
|
err = imgcache.ValidateImageRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, true
|
||||||
|
}
|
||||||
|
|
||||||
// handleImageError converts image service errors to HTTP responses.
|
// handleImageError converts image service errors to HTTP responses.
|
||||||
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, imgcache.ErrSSRFBlocked):
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamError):
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamTimeout):
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
||||||
|
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
||||||
|
errors.Is(err, imageprocessor.ErrTooManyImages):
|
||||||
|
s.respondError(w, "server busy, try again later",
|
||||||
|
http.StatusServiceUnavailable)
|
||||||
default:
|
default:
|
||||||
s.log.Error("image request failed", "error", err)
|
s.log.Error("image request failed", "error", err)
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||||
|
// generator seeded by the shared test signing key. The image service is left
|
||||||
|
// nil: these tests exercise validation that rejects a token before any image
|
||||||
|
// is fetched, so the handler must never reach the service.
|
||||||
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
encGen: encGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return encGen, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEncToken issues a GET for the given token and returns the recorder.
|
||||||
|
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
||||||
|
// token requesting a dimension beyond MaxDimension is rejected with 400
|
||||||
|
// instead of reaching the image processor and libvips.
|
||||||
|
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photo.jpg",
|
||||||
|
Width: 100000,
|
||||||
|
Height: 100000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := getEncToken(srv, token)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
||||||
|
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
||||||
|
// as a 500 from the image processor's default branch.
|
||||||
|
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photo.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
FitMode: imgcache.FitMode("bogus"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := getEncToken(srv, token)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_IfNoneMatch_Returns304 verifies that an image served
|
||||||
|
// through an encrypted URL carries an ETag, and that a request whose
|
||||||
|
// If-None-Match is that ETag is answered 304 Not Modified with no body.
|
||||||
|
func TestHandleImageEnc_IfNoneMatch_Returns304(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
target := encPhotoURL(t, h)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
|
||||||
|
etag := rec.Header().Get("ETag")
|
||||||
|
t.Logf("GET: %d, ETag %q", rec.Code, etag)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || etag == "" {
|
||||||
|
t.Fatalf("GET: status = %d, ETag = %q, want %d and an ETag",
|
||||||
|
rec.Code, etag, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
t.Logf("GET with If-None-Match: %d, %d body bytes", rec.Code, rec.Body.Len())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
||||||
|
t.Errorf("status = %d with %d body bytes, want %d with none",
|
||||||
|
rec.Code, rec.Body.Len(), http.StatusNotModified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_HEAD_ReturnsHeadersOnly verifies that HEAD on an
|
||||||
|
// encrypted URL is answered 200 with the headers GET sends and no body.
|
||||||
|
func TestHandleImageEnc_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Head("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodHead, encPhotoURL(t, h), nil))
|
||||||
|
t.Logf("HEAD: %d, headers %v, %d body bytes",
|
||||||
|
rec.Code, rec.Header(), rec.Body.Len())
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"Content-Type", "Content-Length", "Cache-Control", "ETag",
|
||||||
|
} {
|
||||||
|
if rec.Header().Get(name) == "" {
|
||||||
|
t.Errorf("HEAD response has no %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Errorf("HEAD response body has %d bytes, want none", rec.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,185 @@
|
|||||||
|
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(),
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
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,131 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
||||||
|
// JPEG at photoPath on signedHost at 50x50, made with h's image service.
|
||||||
|
func signedPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Size: imgcache.Size{Width: 50, Height: 50},
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
}, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the
|
||||||
|
// JPEG at photoPath on signedHost at 50x50, made with h's generator.
|
||||||
|
func encPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token, err := h.encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: signedHost,
|
||||||
|
SourcePath: photoPath,
|
||||||
|
Width: 50,
|
||||||
|
Height: 50,
|
||||||
|
Format: imgcache.FormatJPEG,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/v1/e/" + token + "/img.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestIDByMessage reads the JSON log lines in logs and returns the
|
||||||
|
// request_id of each line, by its message.
|
||||||
|
func requestIDByMessage(t *testing.T, logs io.Reader) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
logged := make(map[string]string)
|
||||||
|
|
||||||
|
dec := json.NewDecoder(logs)
|
||||||
|
for dec.More() {
|
||||||
|
var line map[string]any
|
||||||
|
|
||||||
|
err := dec.Decode(&line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding log line: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, _ := line["msg"].(string)
|
||||||
|
requestID, _ := line["request_id"].(string)
|
||||||
|
logged[msg] = requestID
|
||||||
|
}
|
||||||
|
|
||||||
|
return logged
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageLogLinesCarryRequestID verifies that the lines logged when an image
|
||||||
|
// is fetched, converted and served through either image route carry the
|
||||||
|
// request's ID as request_id, as the request log line does, so they can be
|
||||||
|
// found from it.
|
||||||
|
func TestImageLogLinesCarryRequestID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const requestID = "test-request-id"
|
||||||
|
|
||||||
|
imageURLs := map[string]func(*testing.T, *Handlers) string{
|
||||||
|
"/v1/image/": signedPhotoURL,
|
||||||
|
"/v1/e/": encPhotoURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
for route, imageURL := range imageURLs {
|
||||||
|
t.Run(route, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
h, srv := newSignedHostServer(t,
|
||||||
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||||
|
|
||||||
|
ctx := context.WithValue(t.Context(),
|
||||||
|
middleware.RequestIDKey, requestID)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
ctx, http.MethodGet, imageURL(t, h), nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("logged:\n%s", logs.String())
|
||||||
|
|
||||||
|
logged := requestIDByMessage(t, &logs)
|
||||||
|
|
||||||
|
for _, msg := range []string{
|
||||||
|
"upstream fetched", "image converted", "image served",
|
||||||
|
} {
|
||||||
|
got, ok := logged[msg]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no %q line logged", msg)
|
||||||
|
} else if got != requestID {
|
||||||
|
t.Errorf("%q line has request_id %q, want %q",
|
||||||
|
msg, got, requestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServerBusyAnswers503 checks that both image routes answer 503 with
|
||||||
|
// a clear error when the image service gives up waiting for a free upstream
|
||||||
|
// connection or processing slot, wrapped as the service wraps them.
|
||||||
|
func TestServerBusyAnswers503(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
|
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
|
||||||
|
|
||||||
|
for _, err := range []error{
|
||||||
|
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
||||||
|
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
||||||
|
} {
|
||||||
|
plain := httptest.NewRecorder()
|
||||||
|
h.respondImageError(plain, req, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for route, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"/v1/image/": plain, "/v1/e/": encrypted,
|
||||||
|
} {
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("%s for %v: status = %d, want %d",
|
||||||
|
route, err, rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
|
t.Errorf("%s for %v: body = %q, want the server busy error",
|
||||||
|
route, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// Params defines dependencies for Healthcheck.
|
// Params defines dependencies for Healthcheck.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
@@ -53,6 +54,8 @@ func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Response is the JSON response for health checks.
|
// Response is the JSON response for health checks.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // health endpoint response format uses snake_case
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Now string `json:"now"`
|
Now string `json:"now"`
|
||||||
@@ -63,10 +66,6 @@ type Response struct {
|
|||||||
Maintenance bool `json:"maintenance_mode"`
|
Maintenance bool `json:"maintenance_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Healthcheck) uptime() time.Duration {
|
|
||||||
return time.Since(s.StartupTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Healthcheck returns the current health status.
|
// Healthcheck returns the current health status.
|
||||||
func (s *Healthcheck) Healthcheck() *Response {
|
func (s *Healthcheck) Healthcheck() *Response {
|
||||||
resp := &Response{
|
resp := &Response{
|
||||||
@@ -81,3 +80,7 @@ func (s *Healthcheck) Healthcheck() *Response {
|
|||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Healthcheck) uptime() time.Duration {
|
||||||
|
return time.Since(s.StartupTime)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
||||||
|
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
|
||||||
|
// forms, alongside public controls that must stay reachable.
|
||||||
|
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"cgnat-low", "100.64.0.1", true},
|
||||||
|
{"cgnat-high", "100.127.255.254", true},
|
||||||
|
{"ietf-protocol", "192.0.0.1", true},
|
||||||
|
{"benchmark-low", "198.18.0.1", true},
|
||||||
|
{"benchmark-high", "198.19.255.254", true},
|
||||||
|
{"nat64", "64:ff9b::1", true},
|
||||||
|
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
|
||||||
|
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
|
||||||
|
{"cloud-metadata", "169.254.169.254", true},
|
||||||
|
{"public-v4", "8.8.8.8", false},
|
||||||
|
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
|
||||||
|
{"public-v6", "2001:4860:4860::8888", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportOf returns the *http.Transport backing a fetcher, so a test can
|
||||||
|
// exercise the SSRF-safe dialer New installed with the operator blocklist.
|
||||||
|
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
transport, ok := f.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
|
||||||
|
// blocked_networks entry is enforced by the dialer, in addition to the
|
||||||
|
// built-in ranges, while an address outside both stays dialable.
|
||||||
|
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
||||||
|
// blocking it can only come from the operator-supplied list.
|
||||||
|
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||||
|
|
||||||
|
transport := transportOf(t, New(cfg))
|
||||||
|
|
||||||
|
blocked := []string{
|
||||||
|
"198.51.100.5:80", // operator-supplied range
|
||||||
|
"10.0.0.5:80", // built-in RFC 1918, still enforced
|
||||||
|
"100.64.0.1:80", // built-in CGNAT range
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range blocked {
|
||||||
|
t.Run("blocked/"+addr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(context.Background(), "tcp", addr)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("public-not-blocked", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A cancelled context makes the dial fail without touching the
|
||||||
|
// network; the point is only that a public literal outside every
|
||||||
|
// blocked range is not SSRF-blocked.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
|
||||||
|
if errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
||||||
|
// public, so validateURL and the redirect check accept it with no DNS lookup,
|
||||||
|
// while the recording dialer routes it to the local httptest server. The
|
||||||
|
// address is reserved for documentation and is never routed on the network.
|
||||||
|
const testPublicHost = "192.0.2.10"
|
||||||
|
|
||||||
|
// imagePayload is the body served by the fake upstream's image route.
|
||||||
|
const imagePayload = "fake-jpeg-bytes"
|
||||||
|
|
||||||
|
// errUnexpectedDial reports a dial to any host other than testPublicHost, which
|
||||||
|
// would mean SSRF protection let a forbidden target reach the transport.
|
||||||
|
var errUnexpectedDial = errors.New("unexpected dial target")
|
||||||
|
|
||||||
|
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
||||||
|
func upstreamURL(path string) string {
|
||||||
|
return "http://" + testPublicHost + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingDialer records every address the transport asks it to dial and
|
||||||
|
// routes connections for testPublicHost to a real local server, so the SSRF
|
||||||
|
// checks run against a public-looking host while bytes go to httptest.
|
||||||
|
type recordingDialer struct {
|
||||||
|
target string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
dialed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *recordingDialer) dialContext(
|
||||||
|
ctx context.Context,
|
||||||
|
network, addr string,
|
||||||
|
) (net.Conn, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.dialed = append(d.dialed, addr)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if host != testPublicHost {
|
||||||
|
return nil, fmt.Errorf("%w: %s", errUnexpectedDial, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
return dialer.DialContext(ctx, network, d.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialedAddrs returns a copy of the addresses the dialer was asked to reach.
|
||||||
|
func (d *recordingDialer) dialedAddrs() []string {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
return slices.Clone(d.dialed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUpstream launches a fake upstream with the routes the fetch tests
|
||||||
|
// exercise and stops it when the test finishes.
|
||||||
|
func startUpstream(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/html", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = io.WriteString(w, "<html></html>")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/private", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/public", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/image", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/chain", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/redirect/hop", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/redirect/hop", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/image", http.StatusFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// newServerFetcher builds a fetcher whose transport routes testPublicHost to
|
||||||
|
// srv, leaving the real SSRF validation and redirect checks in place.
|
||||||
|
func newServerFetcher(
|
||||||
|
t *testing.T,
|
||||||
|
srv *httptest.Server,
|
||||||
|
cfg *Config,
|
||||||
|
) (*HTTPFetcher, *recordingDialer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AllowHTTP = true
|
||||||
|
|
||||||
|
f := New(cfg)
|
||||||
|
|
||||||
|
transport, ok := f.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||||
|
transport.DialContext = dialer.dialContext
|
||||||
|
|
||||||
|
return f, dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
// testContext returns a context cancelled when the test ends, bounding any
|
||||||
|
// fetch that would otherwise block on a leaked semaphore slot.
|
||||||
|
func testContext(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchImage fetches path from the fake upstream and fails on error.
|
||||||
|
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchExpectError fetches path and fails unless Fetch returns an error.
|
||||||
|
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||||
|
if err == nil {
|
||||||
|
_ = res.Content.Close()
|
||||||
|
|
||||||
|
t.Fatalf("Fetch(%s) = nil error, want an error", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBody fetches path and returns the fully read, closed response body.
|
||||||
|
func fetchBody(t *testing.T, f *HTTPFetcher, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res := fetchImage(t, f, path)
|
||||||
|
defer func() { _ = res.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(res.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// semLen reports how many per-host semaphore slots are currently held.
|
||||||
|
func semLen(f *HTTPFetcher, host string) int {
|
||||||
|
f.hostSemMu.Lock()
|
||||||
|
defer f.hostSemMu.Unlock()
|
||||||
|
|
||||||
|
sem, ok := f.hostSems[host]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(sem.slots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostSemCount reports how many hosts have a semaphore in hostSems.
|
||||||
|
func hostSemCount(f *HTTPFetcher) int {
|
||||||
|
f.hostSemMu.Lock()
|
||||||
|
defer f.hostSemMu.Unlock()
|
||||||
|
|
||||||
|
return len(f.hostSems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, dialer := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range dialer.dialedAddrs() {
|
||||||
|
if strings.Contains(addr, "169.254.169.254") {
|
||||||
|
t.Errorf("dialer connected to the private redirect target: %s", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectToPublicSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
if body := fetchBody(t, f, "/redirect/public"); body != imagePayload {
|
||||||
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRedirectChainSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
if body := fetchBody(t, f, "/redirect/chain"); body != imagePayload {
|
||||||
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsNon2xx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
err := fetchExpectError(t, f, "/status/500")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsDisallowedContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
err := fetchExpectError(t, f, "/html")
|
||||||
|
if !errors.Is(err, ErrInvalidContentType) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrInvalidContentType", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxResponseSize = 8
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
defer func() { _ = res.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(res.Content)
|
||||||
|
if !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Fatalf("read error = %v, want ErrResponseTooLarge", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > cfg.MaxResponseSize {
|
||||||
|
t.Errorf("read %d bytes, exceeds limit %d", len(data), cfg.MaxResponseSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnectionsPerHost = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
err := fetchExpectError(t, f, "/status/500")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost); held != 0 {
|
||||||
|
t.Fatalf("semaphore slot leaked after error: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One slot per host: this fetch proceeds only if the slot was released.
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
_ = res.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSlotReleasedByClose fetches an image over a one-slot host, hands the
|
||||||
|
// open result to consume, and asserts the slot is held before and freed after,
|
||||||
|
// then that a follow-up fetch can still acquire it.
|
||||||
|
func assertSlotReleasedByClose(
|
||||||
|
t *testing.T,
|
||||||
|
consume func(*testing.T, *FetchResult),
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnectionsPerHost = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
if held := semLen(f, testPublicHost); held != 1 {
|
||||||
|
t.Fatalf("slot not held while body is open: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(t, res)
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost); held != 0 {
|
||||||
|
t.Fatalf("slot not released after close: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
next := fetchImage(t, f, "/image")
|
||||||
|
_ = next.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSemaphoreReleasedOnBodyClose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := io.ReadAll(res.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = res.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close body: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
|
||||||
|
_, err := res.Content.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("partial read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = res.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close body: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
|
||||||
|
// rebinding window: even if validateURL saw a public answer earlier, the
|
||||||
|
// dialer independently re-checks the address it is about to connect to. A full
|
||||||
|
// rebinding simulation (a resolver returning public, then private) would mean
|
||||||
|
// replacing the global net.DefaultResolver with a fake DNS server, which is
|
||||||
|
// heavyweight and unsafe to mutate under parallel -race tests. The property is
|
||||||
|
// proven directly here instead: the dialer rejects a private target outright,
|
||||||
|
// which is exactly the check that fires when a validated host later resolves
|
||||||
|
// to a private address.
|
||||||
|
func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, addr := range []string{
|
||||||
|
"169.254.169.254:80", // link-local (cloud metadata)
|
||||||
|
"127.0.0.1:80", // loopback
|
||||||
|
"10.0.0.5:80", // RFC 1918 private
|
||||||
|
} {
|
||||||
|
t.Run(addr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A cancelled context makes the dial fail immediately without touching the
|
||||||
|
// network; the point is only that a public literal is not SSRF-blocked.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a dial error for an unreachable public target")
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("public target was SSRF-blocked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
||||||
|
// protection, connection limits per host and for all hosts together, and
|
||||||
|
// content-type validation.
|
||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"net/netip"
|
||||||
|
neturl "net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher configuration constants.
|
||||||
|
const (
|
||||||
|
DefaultFetchTimeout = 30 * time.Second
|
||||||
|
DefaultMaxResponseSize = 50 << 20 // 50MB
|
||||||
|
DefaultTLSTimeout = 10 * time.Second
|
||||||
|
DefaultMaxIdleConns = 100
|
||||||
|
DefaultIdleConnTimeout = 90 * time.Second
|
||||||
|
DefaultMaxRedirects = 10
|
||||||
|
DefaultMaxConnectionsPerHost = 20
|
||||||
|
DefaultMaxConnections = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
||||||
|
// MaxConnections fetches are already in progress.
|
||||||
|
const ConnectionWaitTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// MIME content types.
|
||||||
|
const (
|
||||||
|
contentTypeJPEG = "image/jpeg"
|
||||||
|
contentTypePNG = "image/png"
|
||||||
|
contentTypeGIF = "image/gif"
|
||||||
|
contentTypeWebP = "image/webp"
|
||||||
|
contentTypeAVIF = "image/avif"
|
||||||
|
contentTypeSVG = "image/svg+xml"
|
||||||
|
contentTypeOctetStream = "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loopback addresses blocked by SSRF protection.
|
||||||
|
const (
|
||||||
|
localhostIPv4 = "127.0.0.1"
|
||||||
|
localhostIPv6 = "::1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
||||||
|
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
||||||
|
// already cover. They are always blocked, in addition to any
|
||||||
|
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
|
||||||
|
// before matching, so these IPv4 ranges are caught in both forms.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // immutable built-in blocklist
|
||||||
|
var builtinBlockedPrefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
||||||
|
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
||||||
|
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetcher errors.
|
||||||
|
var (
|
||||||
|
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||||
|
ErrInvalidHost = errors.New("invalid or unresolvable host")
|
||||||
|
ErrUnsupportedScheme = errors.New("only HTTPS is supported")
|
||||||
|
ErrResponseTooLarge = errors.New("response exceeds maximum size")
|
||||||
|
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
||||||
|
ErrUpstreamError = errors.New("upstream server error")
|
||||||
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||||
|
ErrTooManyConnections = errors.New("too many concurrent upstream connections")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Internal fetcher errors.
|
||||||
|
var (
|
||||||
|
errTooManyRedirects = errors.New("too many redirects")
|
||||||
|
errConnectFailed = errors.New("failed to connect")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher retrieves content from upstream origins.
|
||||||
|
type Fetcher interface {
|
||||||
|
// Fetch retrieves content from the given URL.
|
||||||
|
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchResult contains the result of fetching from upstream.
|
||||||
|
type FetchResult struct {
|
||||||
|
// Content is the raw image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes (-1 if unknown).
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type from upstream.
|
||||||
|
ContentType string
|
||||||
|
// Headers contains all response headers from upstream.
|
||||||
|
Headers map[string][]string
|
||||||
|
// StatusCode is the HTTP status code from upstream.
|
||||||
|
StatusCode int
|
||||||
|
// FetchDurationMs is how long the fetch took in milliseconds.
|
||||||
|
FetchDurationMs int64
|
||||||
|
// RemoteAddr is the IP:port of the upstream server.
|
||||||
|
RemoteAddr string
|
||||||
|
// HTTPVersion is the protocol version (e.g., "1.1", "2.0").
|
||||||
|
HTTPVersion string
|
||||||
|
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3").
|
||||||
|
TLSVersion string
|
||||||
|
// TLSCipherSuite is the negotiated cipher suite name.
|
||||||
|
TLSCipherSuite string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds configuration for the upstream fetcher.
|
||||||
|
type Config struct {
|
||||||
|
// Timeout for upstream requests.
|
||||||
|
Timeout time.Duration
|
||||||
|
// MaxResponseSize is the maximum allowed response body size.
|
||||||
|
MaxResponseSize int64
|
||||||
|
// UserAgent to send to upstream servers.
|
||||||
|
UserAgent string
|
||||||
|
// AllowedContentTypes is an allow list of MIME types to accept.
|
||||||
|
AllowedContentTypes []string
|
||||||
|
// AllowHTTP allows non-TLS connections (for testing only).
|
||||||
|
AllowHTTP bool
|
||||||
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
|
MaxConnectionsPerHost int
|
||||||
|
// MaxConnections limits concurrent connections to all upstream hosts
|
||||||
|
// together.
|
||||||
|
MaxConnections int
|
||||||
|
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||||
|
// dialer, in addition to the always-enforced built-in ranges.
|
||||||
|
BlockedNetworks []netip.Prefix
|
||||||
|
// 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.
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Timeout: DefaultFetchTimeout,
|
||||||
|
MaxResponseSize: DefaultMaxResponseSize,
|
||||||
|
UserAgent: "pixa/1.0",
|
||||||
|
AllowedContentTypes: []string{
|
||||||
|
contentTypeJPEG,
|
||||||
|
contentTypePNG,
|
||||||
|
contentTypeGIF,
|
||||||
|
contentTypeWebP,
|
||||||
|
contentTypeAVIF,
|
||||||
|
contentTypeSVG,
|
||||||
|
},
|
||||||
|
AllowHTTP: false,
|
||||||
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
|
MaxConnections: DefaultMaxConnections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
||||||
|
// per host and for all hosts together.
|
||||||
|
type HTTPFetcher struct {
|
||||||
|
client *http.Client
|
||||||
|
config *Config
|
||||||
|
// hostSems holds the semaphore of each host with a fetch holding or
|
||||||
|
// waiting for one of its slots; the entry is removed when the host's
|
||||||
|
// last such fetch gives its slot back or stops waiting.
|
||||||
|
hostSems map[string]*hostSemaphore
|
||||||
|
hostSemMu sync.Mutex // protects hostSems and each entry's count
|
||||||
|
// allHostsSemaphore has one slot per connection allowed to all hosts
|
||||||
|
// together (config.MaxConnections).
|
||||||
|
allHostsSemaphore chan struct{}
|
||||||
|
// connectionWaitTimeout is ConnectionWaitTimeout; tests shorten it.
|
||||||
|
connectionWaitTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostSemaphore is one host's connection slots
|
||||||
|
// (config.MaxConnectionsPerHost) and the number of fetches holding or
|
||||||
|
// waiting for one of them.
|
||||||
|
type hostSemaphore struct {
|
||||||
|
slots chan struct{}
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
|
func New(config *Config) *HTTPFetcher {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unless config.DialContext replaces it, the transport connects with
|
||||||
|
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
||||||
|
// (closing the DNS-rebinding window) against both the built-in ranges
|
||||||
|
// and the operator-supplied blocklist.
|
||||||
|
dialContext := config.DialContext
|
||||||
|
if dialContext == nil {
|
||||||
|
dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialContext,
|
||||||
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||||
|
MaxIdleConns: DefaultMaxIdleConns,
|
||||||
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
// Don't follow redirects automatically - we need to validate each hop
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= DefaultMaxRedirects {
|
||||||
|
return errTooManyRedirects
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the redirect target
|
||||||
|
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("redirect blocked: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTTPFetcher{
|
||||||
|
client: client,
|
||||||
|
config: config,
|
||||||
|
hostSems: make(map[string]*hostSemaphore),
|
||||||
|
allHostsSemaphore: make(chan struct{}, config.MaxConnections),
|
||||||
|
connectionWaitTimeout: ConnectionWaitTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch retrieves content from the given URL with SSRF protection. When
|
||||||
|
// MaxConnections fetches are already in progress, it waits up to
|
||||||
|
// ConnectionWaitTimeout for one to finish, then fails with
|
||||||
|
// ErrTooManyConnections.
|
||||||
|
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||||
|
// Validate URL before making request
|
||||||
|
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := f.acquireConnection(ctx, extractHost(url))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we fail before returning a result, release the connection
|
||||||
|
success := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if !success {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
parsedURL, err := neturl.Parse(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: parsedURL,
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", f.config.UserAgent)
|
||||||
|
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
||||||
|
|
||||||
|
// The ID of the request this fetch serves, so the fetch can be found in
|
||||||
|
// the upstream host's logs
|
||||||
|
requestID := middleware.GetReqID(ctx)
|
||||||
|
if requestID != "" {
|
||||||
|
req.Header.Set(middleware.RequestIDHeader, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use httptrace to capture connection details
|
||||||
|
var remoteAddr string
|
||||||
|
|
||||||
|
trace := &httptrace.ClientTrace{
|
||||||
|
GotConn: func(info httptrace.GotConnInfo) {
|
||||||
|
if info.Conn != nil {
|
||||||
|
remoteAddr = info.Conn.RemoteAddr().String()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
resp, err := f.client.Do(req)
|
||||||
|
|
||||||
|
fetchDuration := time.Since(startTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, ErrUpstreamTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark success so defer doesn't release the connection; closing the
|
||||||
|
// result's Content does
|
||||||
|
success = true
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireConnection takes a slot for host, then one of the slots shared by
|
||||||
|
// all hosts, and returns the func that gives both back. The host's slot
|
||||||
|
// comes first, so fetches queued for one busy host hold no shared slot.
|
||||||
|
// Only the wait for a shared slot is bounded: after connectionWaitTimeout
|
||||||
|
// it fails with ErrTooManyConnections.
|
||||||
|
func (f *HTTPFetcher) acquireConnection(
|
||||||
|
ctx context.Context, host string,
|
||||||
|
) (func(), error) {
|
||||||
|
hostSem := f.getHostSemaphore(host)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case hostSem <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
f.putHostSemaphore(host)
|
||||||
|
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case f.allHostsSemaphore <- struct{}{}:
|
||||||
|
case <-time.After(f.connectionWaitTimeout):
|
||||||
|
<-hostSem
|
||||||
|
f.putHostSemaphore(host)
|
||||||
|
|
||||||
|
return nil, ErrTooManyConnections
|
||||||
|
case <-ctx.Done():
|
||||||
|
<-hostSem
|
||||||
|
f.putHostSemaphore(host)
|
||||||
|
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
<-hostSem
|
||||||
|
f.putHostSemaphore(host)
|
||||||
|
<-f.allHostsSemaphore
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHostSemaphore returns the semaphore for a host, creating it if
|
||||||
|
// necessary, and counts the caller among the fetches using it. The caller
|
||||||
|
// calls putHostSemaphore once it holds no slot and waits for none.
|
||||||
|
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||||
|
f.hostSemMu.Lock()
|
||||||
|
defer f.hostSemMu.Unlock()
|
||||||
|
|
||||||
|
sem, ok := f.hostSems[host]
|
||||||
|
if !ok {
|
||||||
|
sem = &hostSemaphore{
|
||||||
|
slots: make(chan struct{}, f.config.MaxConnectionsPerHost),
|
||||||
|
}
|
||||||
|
f.hostSems[host] = sem
|
||||||
|
}
|
||||||
|
|
||||||
|
sem.count++
|
||||||
|
|
||||||
|
return sem.slots
|
||||||
|
}
|
||||||
|
|
||||||
|
// putHostSemaphore stops counting the caller among the fetches using the
|
||||||
|
// host's semaphore, and removes the semaphore when no fetch uses it.
|
||||||
|
func (f *HTTPFetcher) putHostSemaphore(host string) {
|
||||||
|
f.hostSemMu.Lock()
|
||||||
|
defer f.hostSemMu.Unlock()
|
||||||
|
|
||||||
|
sem := f.hostSems[host]
|
||||||
|
|
||||||
|
sem.count--
|
||||||
|
if sem.count == 0 {
|
||||||
|
delete(f.hostSems, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildResult validates the upstream response and assembles a FetchResult
|
||||||
|
// whose Content calls release when closed.
|
||||||
|
func (f *HTTPFetcher) buildResult(
|
||||||
|
resp *http.Response,
|
||||||
|
remoteAddr string,
|
||||||
|
fetchDuration time.Duration,
|
||||||
|
release func(),
|
||||||
|
) (*FetchResult, error) {
|
||||||
|
// Extract HTTP version (strip "HTTP/" prefix)
|
||||||
|
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
||||||
|
|
||||||
|
// Extract TLS info if available
|
||||||
|
var tlsVersion, tlsCipherSuite string
|
||||||
|
|
||||||
|
if resp.TLS != nil {
|
||||||
|
tlsVersion = tls.VersionName(resp.TLS.Version)
|
||||||
|
tlsCipherSuite = tls.CipherSuiteName(resp.TLS.CipherSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: status %d", ErrUpstreamError, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate content type
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !f.isAllowedContentType(contentType) {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap body with size limiter and semaphore releaser
|
||||||
|
limitedBody := &limitedReader{
|
||||||
|
reader: resp.Body,
|
||||||
|
remaining: f.config.MaxResponseSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FetchResult{
|
||||||
|
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, release},
|
||||||
|
ContentLength: resp.ContentLength,
|
||||||
|
ContentType: contentType,
|
||||||
|
Headers: resp.Header,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
FetchDurationMs: fetchDuration.Milliseconds(),
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
HTTPVersion: httpVersion,
|
||||||
|
TLSVersion: tlsVersion,
|
||||||
|
TLSCipherSuite: tlsCipherSuite,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedContentType checks if the content type is in the allow list.
|
||||||
|
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||||
|
// Extract the MIME type without parameters
|
||||||
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
|
|
||||||
|
for _, allowed := range f.config.AllowedContentTypes {
|
||||||
|
if strings.EqualFold(mediaType, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||||
|
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||||
|
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
||||||
|
return ErrUnsupportedScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse to extract host
|
||||||
|
host := extractHost(rawURL)
|
||||||
|
if host == "" {
|
||||||
|
return ErrInvalidHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove port if present
|
||||||
|
h, _, err := net.SplitHostPort(host)
|
||||||
|
if err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block obvious localhost patterns
|
||||||
|
if isLocalhost(host) {
|
||||||
|
return ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the host to check IP addresses
|
||||||
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
||||||
|
return isPrivateIP(addr.IP)
|
||||||
|
})
|
||||||
|
if private {
|
||||||
|
return ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHost extracts the host from a URL string.
|
||||||
|
func extractHost(rawURL string) string {
|
||||||
|
// Simple extraction without full URL parsing
|
||||||
|
url := rawURL
|
||||||
|
if idx := strings.Index(url, "://"); idx != -1 {
|
||||||
|
url = url[idx+3:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(url, "/"); idx != -1 {
|
||||||
|
url = url[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(url, "?"); idx != -1 {
|
||||||
|
url = url[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalhost checks if the host is localhost.
|
||||||
|
func isLocalhost(host string) bool {
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
|
return host == "localhost" ||
|
||||||
|
host == localhostIPv4 ||
|
||||||
|
host == localhostIPv6 ||
|
||||||
|
host == "[::1]" ||
|
||||||
|
strings.HasSuffix(host, ".localhost") ||
|
||||||
|
strings.HasSuffix(host, ".local")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP checks if an IP is private, loopback, or otherwise internal.
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for loopback
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private ranges
|
||||||
|
if ip.IsPrivate() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for link-local
|
||||||
|
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unspecified (0.0.0.0 or ::)
|
||||||
|
if ip.IsUnspecified() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for multicast
|
||||||
|
if ip.IsMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional checks for IPv4
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
// 169.254.0.0/16 - Link local
|
||||||
|
if ip4[0] == 169 && ip4[1] == 254 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 0.0.0.0/8 - Current network
|
||||||
|
if ip4[0] == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special-use ranges the net.IP predicates above do not cover.
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBlockedIP reports whether ip is refused, either by the built-in
|
||||||
|
// internal-range check or by one of the operator-supplied prefixes.
|
||||||
|
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
||||||
|
if isPrivateIP(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
||||||
|
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
||||||
|
// blocklist; this entry point enforces the built-in ranges alone.
|
||||||
|
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialSSRFSafe(ctx, network, addr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
||||||
|
// internal range or operator-supplied blocked prefix, closing the
|
||||||
|
// DNS-rebinding window at connect time.
|
||||||
|
func dialSSRFSafe(
|
||||||
|
ctx context.Context,
|
||||||
|
network, addr string,
|
||||||
|
blocked []netip.Prefix,
|
||||||
|
) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the address
|
||||||
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all resolved IPs
|
||||||
|
for _, ip := range ips {
|
||||||
|
if isBlockedIP(ip, blocked) {
|
||||||
|
return nil, ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect using the first valid IP
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
addr := net.JoinHostPort(ip.String(), port)
|
||||||
|
|
||||||
|
conn, err := dialer.DialContext(ctx, network, addr)
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitedReader wraps a reader and limits the number of bytes read.
|
||||||
|
type limitedReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
remaining int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *limitedReader) Read(p []byte) (int, error) {
|
||||||
|
if r.remaining <= 0 {
|
||||||
|
return 0, ErrResponseTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(p)) > r.remaining {
|
||||||
|
p = p[:r.remaining]
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
r.remaining -= int64(n)
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// semaphoreReleasingReadCloser releases the fetch's connection slots when
|
||||||
|
// closed.
|
||||||
|
type semaphoreReleasingReadCloser struct {
|
||||||
|
*limitedReader
|
||||||
|
|
||||||
|
closer io.Closer
|
||||||
|
release func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *semaphoreReleasingReadCloser) Close() error {
|
||||||
|
err := r.closer.Close()
|
||||||
|
r.release()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testHost is the hostname used by mock fetch tests.
|
||||||
|
const testHost = "example.com"
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxResponseSize != DefaultMaxResponseSize {
|
||||||
|
t.Errorf("MaxResponseSize = %d, want %d", cfg.MaxResponseSize, DefaultMaxResponseSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxConnectionsPerHost != DefaultMaxConnectionsPerHost {
|
||||||
|
t.Errorf("MaxConnectionsPerHost = %d, want %d",
|
||||||
|
cfg.MaxConnectionsPerHost, DefaultMaxConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP should default to false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedContentTypes) == 0 {
|
||||||
|
t.Error("AllowedContentTypes should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := New(nil)
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("New(nil) returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config == nil {
|
||||||
|
t.Fatal("config should be populated from DefaultConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", f.config.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := New(DefaultConfig())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
contentType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{contentTypeJPEG, true},
|
||||||
|
{contentTypePNG, true},
|
||||||
|
{contentTypeWebP, true},
|
||||||
|
{"image/jpeg; charset=utf-8", true},
|
||||||
|
{"IMAGE/JPEG", true},
|
||||||
|
{"text/html", false},
|
||||||
|
{contentTypeOctetStream, false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.contentType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := f.isAllowedContentType(tc.contentType)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/path", testHost},
|
||||||
|
{"http://example.com:8080/path", "example.com:8080"},
|
||||||
|
{"https://example.com", testHost},
|
||||||
|
{"https://example.com?q=1", testHost},
|
||||||
|
{"example.com/path", testHost},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.url, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := extractHost(tc.url)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLocalhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"localhost", true},
|
||||||
|
{"LOCALHOST", true},
|
||||||
|
{localhostIPv4, true},
|
||||||
|
{localhostIPv6, true},
|
||||||
|
{"[::1]", true},
|
||||||
|
{"foo.localhost", true},
|
||||||
|
{"foo.local", true},
|
||||||
|
{testHost, false},
|
||||||
|
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := isLocalhost(tc.host)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{localhostIPv4, true}, // loopback
|
||||||
|
{"10.0.0.1", true}, // private
|
||||||
|
{"192.168.1.1", true}, // private
|
||||||
|
{"172.16.0.1", true}, // private
|
||||||
|
{"169.254.1.1", true}, // link-local
|
||||||
|
{"0.0.0.0", true}, // unspecified
|
||||||
|
{"224.0.0.1", true}, // multicast
|
||||||
|
{localhostIPv6, true}, // IPv6 loopback
|
||||||
|
{"fe80::1", true}, // IPv6 link-local
|
||||||
|
{"8.8.8.8", false}, // public
|
||||||
|
{"2001:4860:4860::8888", false}, // public IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.ip, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPrivateIP(nil) {
|
||||||
|
t.Error("isPrivateIP(nil) should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "http://example.com/path", false)
|
||||||
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
||||||
|
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
||||||
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||||
|
if errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "https://localhost/path", false)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "https:///path", false)
|
||||||
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.ContentType != contentTypeJPEG {
|
||||||
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "fake-jpeg-data" {
|
||||||
|
t.Errorf("Content = %q, want %q", string(data), "fake-jpeg-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ContentLength != int64(len("fake-jpeg-data")) {
|
||||||
|
t.Errorf("ContentLength = %d, want %d", result.ContentLength, len("fake-jpeg-data"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
|
||||||
|
}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectContentTypeFromPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"foo/bar.jpg", contentTypeJPEG},
|
||||||
|
{"foo/bar.JPG", contentTypeJPEG},
|
||||||
|
{"foo/bar.jpeg", contentTypeJPEG},
|
||||||
|
{"foo/bar.png", contentTypePNG},
|
||||||
|
{"foo/bar.gif", contentTypeGIF},
|
||||||
|
{"foo/bar.webp", contentTypeWebP},
|
||||||
|
{"foo/bar.avif", contentTypeAVIF},
|
||||||
|
{"foo/bar.svg", contentTypeSVG},
|
||||||
|
{"foo/bar.bin", contentTypeOctetStream},
|
||||||
|
{"foo/bar", contentTypeOctetStream},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := detectContentTypeFromPath(tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
src := make([]byte, 100)
|
||||||
|
r := &limitedReader{
|
||||||
|
reader: &byteReader{data: src},
|
||||||
|
remaining: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Read error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 50 {
|
||||||
|
t.Errorf("read %d bytes, should be capped at 50", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain until limit is exhausted.
|
||||||
|
total := n
|
||||||
|
for total < 50 {
|
||||||
|
nn, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("during drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total += nn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the limit is exhausted — next read should error.
|
||||||
|
_, err = r.Read(buf)
|
||||||
|
if !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Errorf("exhausted Read error = %v, want ErrResponseTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteReader is a minimal io.Reader over a byte slice for testing.
|
||||||
|
type byteReader struct {
|
||||||
|
data []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *byteReader) Read(p []byte) (int, error) {
|
||||||
|
if r.pos >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n := copy(p, r.data[r.pos:])
|
||||||
|
r.pos += n
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
||||||
|
// port. Each port is a different host to the per-host limit, while the test
|
||||||
|
// dialer sends every port to the one test server.
|
||||||
|
func imageURLOnPort(port int) string {
|
||||||
|
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
||||||
|
"/image"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfigMaxConnections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := DefaultConfig().MaxConnections; got != DefaultMaxConnections {
|
||||||
|
t.Errorf("MaxConnections = %d, want %d", got, DefaultMaxConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
||||||
|
// counts the fetches to every host together, apart from the per-host
|
||||||
|
// limit: with MaxConnections at 2 and two responses open from two hosts, a
|
||||||
|
// fetch from a third host, which has nothing open, waits the whole wait
|
||||||
|
// timeout and fails with ErrTooManyConnections. Closing one response lets
|
||||||
|
// it through.
|
||||||
|
func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 2
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = second.Content.Close() }()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
|
||||||
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
|
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waited := time.Since(start); waited < f.connectionWaitTimeout {
|
||||||
|
t.Errorf("third Fetch() failed after %v, before waiting %v",
|
||||||
|
waited, f.connectionWaitTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost+":83"); held != 0 {
|
||||||
|
t.Errorf("the refused fetch kept its host's slot: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = first.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close first body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = third.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchFreesHostSlotWhenContextEndsWaitingForConnection checks that a
|
||||||
|
// fetch whose request context ends while it waits for a connection shared
|
||||||
|
// by all hosts gives its host's slot back. With MaxConnections at 1 and one
|
||||||
|
// response open, a fetch from another host takes that host's slot and waits;
|
||||||
|
// its context ends long before the 10 second wait timeout.
|
||||||
|
func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
|
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = first.Content.Close() }()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = f.Fetch(ctx, imageURLOnPort(82))
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := semLen(f, testPublicHost+":82"); held != 0 {
|
||||||
|
t.Errorf("the fetch kept its host's slot after its context ended: "+
|
||||||
|
"%d held", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchRemovesIdleHostSemaphores checks that a host's semaphore is
|
||||||
|
// removed once no fetch holds or waits for one of its slots: after 100
|
||||||
|
// concurrent fetches from 50 hosts have all finished, no semaphore is left.
|
||||||
|
func TestFetchRemovesIdleHostSemaphores(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range 100 {
|
||||||
|
wg.Go(func() {
|
||||||
|
res, err := f.Fetch(ctx, imageURLOnPort(1+i%50))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Fetch() error = %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = res.Content.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if n := hostSemCount(f); n != 0 {
|
||||||
|
t.Errorf("%d host semaphores left after every fetch finished, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchRemovesHostSemaphoreWhenNoConnection checks that a fetch that
|
||||||
|
// ends without a connection leaves no semaphore behind: when it is refused
|
||||||
|
// after waiting for a connection shared by all hosts, when its context ends
|
||||||
|
// while it waits for its host's slot, and when its context ends while it
|
||||||
|
// waits for a connection shared by all hosts, long before the 10 second
|
||||||
|
// wait timeout.
|
||||||
|
func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 1
|
||||||
|
cfg.MaxConnectionsPerHost = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
open, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = f.Fetch(testContext(t), imageURLOnPort(82))
|
||||||
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
|
t.Fatalf("Fetch() from another host: error = %v, "+
|
||||||
|
"want ErrTooManyConnections", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = f.Fetch(ctx, imageURLOnPort(81))
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("Fetch() from the busy host: error = %v, "+
|
||||||
|
"want context.DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to the 10 second wait, so the next fetch's context ends first.
|
||||||
|
f.connectionWaitTimeout = ConnectionWaitTimeout
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = f.Fetch(ctx, imageURLOnPort(83))
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
|
||||||
|
"want context.DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = open.Content.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close first body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := hostSemCount(f); n != 0 {
|
||||||
|
t.Errorf("%d host semaphores left after every fetch finished, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchReleasesConnectionOnError checks that a fetch that fails after
|
||||||
|
// taking its connection gives it back: with MaxConnections at 1, the slot
|
||||||
|
// must be free after the failure and the next fetch must succeed.
|
||||||
|
func TestFetchReleasesConnectionOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
||||||
|
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
|
||||||
|
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
||||||
|
// dialer refuses every host but testPublicHost.
|
||||||
|
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxConnections = 1
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := f.Fetch(testContext(t), tc.url)
|
||||||
|
if !errors.Is(err, tc.want) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(f.allHostsSemaphore); held != 0 {
|
||||||
|
t.Fatalf("connection still held after the error: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := fetchImage(t, f, "/image")
|
||||||
|
_ = res.Content.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
package imgcache
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockFetcher implements the Fetcher interface using an embedded filesystem.
|
// errEmptyURLPath is returned when a mock URL has no usable path.
|
||||||
|
var errEmptyURLPath = errors.New("empty URL path")
|
||||||
|
|
||||||
|
// MockFetcher implements Fetcher using an embedded filesystem.
|
||||||
// Files are organized as: hostname/path/to/file.ext
|
// Files are organized as: hostname/path/to/file.ext
|
||||||
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
|
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
|
// NewMock creates a new mock fetcher backed by the given filesystem.
|
||||||
func NewMockFetcher(fsys fs.FS) *MockFetcher {
|
func NewMock(fsys fs.FS) *MockFetcher {
|
||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
contentType := detectContentTypeFromPath(path)
|
contentType := detectContentTypeFromPath(path)
|
||||||
|
|
||||||
return &FetchResult{
|
return &FetchResult{
|
||||||
Content: f.(io.ReadCloser),
|
Content: f,
|
||||||
ContentLength: stat.Size(),
|
ContentLength: stat.Size(),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Headers: make(http.Header),
|
Headers: make(http.Header),
|
||||||
@@ -86,7 +88,7 @@ func urlToFSPath(rawURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return "", errors.New("empty URL path")
|
return "", errEmptyURLPath
|
||||||
}
|
}
|
||||||
|
|
||||||
return url, nil
|
return url, nil
|
||||||
@@ -98,18 +100,18 @@ func detectContentTypeFromPath(path string) string {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
||||||
return "image/jpeg"
|
return contentTypeJPEG
|
||||||
case strings.HasSuffix(path, ".png"):
|
case strings.HasSuffix(path, ".png"):
|
||||||
return "image/png"
|
return contentTypePNG
|
||||||
case strings.HasSuffix(path, ".gif"):
|
case strings.HasSuffix(path, ".gif"):
|
||||||
return "image/gif"
|
return contentTypeGIF
|
||||||
case strings.HasSuffix(path, ".webp"):
|
case strings.HasSuffix(path, ".webp"):
|
||||||
return "image/webp"
|
return contentTypeWebP
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return "image/avif"
|
return contentTypeAVIF
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return "image/svg+xml"
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return contentTypeOctetStream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFetchSendsRequestID verifies that a fetch sends the ID of the request
|
||||||
|
// it serves, which the RequestID middleware stores in the request context,
|
||||||
|
// to the upstream host as X-Request-Id, so the fetch can be found in that
|
||||||
|
// host's logs.
|
||||||
|
func TestFetchSendsRequestID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const requestID = "test-request-id"
|
||||||
|
|
||||||
|
received := make(chan string, 1)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
received <- r.Header.Get("X-Request-Id")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
|
||||||
|
|
||||||
|
res, err := f.Fetch(ctx, upstreamURL("/image"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = res.Content.Close()
|
||||||
|
|
||||||
|
got := <-received
|
||||||
|
t.Logf("upstream received X-Request-Id %q", got)
|
||||||
|
|
||||||
|
if got != requestID {
|
||||||
|
t.Errorf("upstream X-Request-Id = %q, want %q", got, requestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
// Package imageprocessor provides image format conversion and resizing using libvips.
|
||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
||||||
|
var vipsOnce sync.Once
|
||||||
|
|
||||||
|
// initVips initializes libvips with quiet logging, one worker thread per
|
||||||
|
// image and no operation cache. Process already works on one image per CPU
|
||||||
|
// by default, so more threads per image would only compete for the CPUs.
|
||||||
|
// Each request decodes different source bytes, so the operation cache
|
||||||
|
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
||||||
|
// repeated requests are served from pixa's disk cache instead.
|
||||||
|
func initVips() {
|
||||||
|
vipsOnce.Do(func() {
|
||||||
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
||||||
|
vips.Startup(&vips.Config{
|
||||||
|
ConcurrencyLevel: 1,
|
||||||
|
MaxCacheSize: 0,
|
||||||
|
MaxCacheMem: 0,
|
||||||
|
MaxCacheFiles: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format represents supported output image formats.
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
// Supported image output formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal Format = "orig"
|
||||||
|
FormatJPEG Format = "jpeg"
|
||||||
|
FormatPNG Format = "png"
|
||||||
|
FormatWebP Format = "webp"
|
||||||
|
FormatAVIF Format = "avif"
|
||||||
|
FormatGIF Format = "gif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FitMode represents how to fit an image into requested dimensions.
|
||||||
|
type FitMode string
|
||||||
|
|
||||||
|
// Supported image fit modes.
|
||||||
|
const (
|
||||||
|
FitCover FitMode = "cover"
|
||||||
|
FitContain FitMode = "contain"
|
||||||
|
FitFill FitMode = "fill"
|
||||||
|
FitInside FitMode = "inside"
|
||||||
|
FitOutside FitMode = "outside"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
|
// Size represents requested image dimensions.
|
||||||
|
type Size struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request holds the parameters for image processing.
|
||||||
|
type Request struct {
|
||||||
|
Size Size
|
||||||
|
Format Format
|
||||||
|
Quality int
|
||||||
|
FitMode FitMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result contains the output of image processing.
|
||||||
|
type Result struct {
|
||||||
|
// Content is the processed image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes.
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type of the output.
|
||||||
|
ContentType string
|
||||||
|
// Width is the output image width.
|
||||||
|
Width int
|
||||||
|
// Height is the output image height.
|
||||||
|
Height int
|
||||||
|
// InputWidth is the original image width before processing.
|
||||||
|
InputWidth int
|
||||||
|
// InputHeight is the original image height before processing.
|
||||||
|
InputHeight int
|
||||||
|
// InputFormat is the detected input format (e.g., "jpeg", "png").
|
||||||
|
InputFormat string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
||||||
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
||||||
|
const MaxInputDimension = 8192
|
||||||
|
|
||||||
|
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
|
||||||
|
// This matches the default upstream fetcher limit.
|
||||||
|
const DefaultMaxInputBytes = 50 << 20
|
||||||
|
|
||||||
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
||||||
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||||
|
|
||||||
|
// ErrInputDataTooLarge is returned when the raw input data exceeds the
|
||||||
|
// configured byte limit.
|
||||||
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||||
|
|
||||||
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
||||||
|
// not supported.
|
||||||
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
|
// processed and none finishes within ProcessingWaitTimeout.
|
||||||
|
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||||
|
|
||||||
|
// ProcessingWaitTimeout is how long Process waits for a free slot when
|
||||||
|
// MaxConcurrentProcessing images are already being processed.
|
||||||
|
const ProcessingWaitTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// ImageProcessor implements image transformation using libvips via govips.
|
||||||
|
type ImageProcessor struct {
|
||||||
|
maxInputBytes int64
|
||||||
|
// processingSemaphore has one slot per image that may be processed at
|
||||||
|
// once. Process holds a slot from before it reads its input until it
|
||||||
|
// returns, so the input, the decoded image and the output all count.
|
||||||
|
processingSemaphore chan struct{}
|
||||||
|
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
||||||
|
processingWaitTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params holds configuration for creating an ImageProcessor.
|
||||||
|
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
||||||
|
type Params struct {
|
||||||
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
|
MaxInputBytes int64
|
||||||
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
|
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
||||||
|
MaxConcurrentProcessing int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new image processor with the given parameters.
|
||||||
|
// A zero-value Params{} uses sensible defaults.
|
||||||
|
func New(params Params) *ImageProcessor {
|
||||||
|
initVips()
|
||||||
|
|
||||||
|
maxInputBytes := params.MaxInputBytes
|
||||||
|
if maxInputBytes <= 0 {
|
||||||
|
maxInputBytes = DefaultMaxInputBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
||||||
|
if maxConcurrentProcessing <= 0 {
|
||||||
|
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImageProcessor{
|
||||||
|
maxInputBytes: maxInputBytes,
|
||||||
|
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
||||||
|
processingWaitTimeout: ProcessingWaitTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process transforms an image according to the request. When
|
||||||
|
// MaxConcurrentProcessing images are already being processed, it waits up
|
||||||
|
// to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
|
// ErrTooManyImages.
|
||||||
|
func (p *ImageProcessor) Process(
|
||||||
|
ctx context.Context,
|
||||||
|
input io.Reader,
|
||||||
|
req *Request,
|
||||||
|
) (*Result, error) {
|
||||||
|
release, err := p.acquireSlot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
// Read input with a size limit to prevent unbounded memory consumption.
|
||||||
|
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
||||||
|
// the limit without consuming additional memory.
|
||||||
|
limited := io.LimitReader(input, p.maxInputBytes+1)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > p.maxInputBytes {
|
||||||
|
return nil, ErrInputDataTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode image
|
||||||
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||||
|
}
|
||||||
|
defer img.Close()
|
||||||
|
|
||||||
|
// Turn the image upright now: encode strips the EXIF orientation tag,
|
||||||
|
// and sizes below must be worked out on the upright image.
|
||||||
|
err = img.AutoRotate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get original dimensions
|
||||||
|
origWidth := img.Width()
|
||||||
|
origHeight := img.Height()
|
||||||
|
|
||||||
|
// Detect input format
|
||||||
|
inputFormat := p.detectFormat(img)
|
||||||
|
|
||||||
|
// Validate input dimensions to prevent DoS via decompression bombs
|
||||||
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||||
|
return nil, ErrInputTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target dimensions
|
||||||
|
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
||||||
|
|
||||||
|
// Resize if needed
|
||||||
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
|
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output format
|
||||||
|
outputFormat := req.Format
|
||||||
|
if outputFormat == FormatOriginal || outputFormat == "" {
|
||||||
|
outputFormat = p.formatFromString(inputFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to target format
|
||||||
|
output, err := p.encode(img, outputFormat, req.Quality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Result{
|
||||||
|
Content: io.NopCloser(bytes.NewReader(output)),
|
||||||
|
ContentLength: int64(len(output)),
|
||||||
|
ContentType: FormatToMIME(outputFormat),
|
||||||
|
Width: img.Width(),
|
||||||
|
Height: img.Height(),
|
||||||
|
InputWidth: origWidth,
|
||||||
|
InputHeight: origHeight,
|
||||||
|
InputFormat: inputFormat,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetDimensions calculates the output dimensions for a requested size,
|
||||||
|
// scaling proportionally when only one dimension is given and keeping the
|
||||||
|
// original dimensions when both are zero.
|
||||||
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
||||||
|
switch {
|
||||||
|
case size.Width == 0 && size.Height == 0:
|
||||||
|
// Both are 0: keep original size
|
||||||
|
return origWidth, origHeight
|
||||||
|
case size.Width == 0:
|
||||||
|
// Only height specified: calculate width proportionally
|
||||||
|
return origWidth * size.Height / origHeight, size.Height
|
||||||
|
case size.Height == 0:
|
||||||
|
// Only width specified: calculate height proportionally
|
||||||
|
return size.Width, origHeight * size.Width / origWidth
|
||||||
|
default:
|
||||||
|
return size.Width, size.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME types for the supported image formats.
|
||||||
|
const (
|
||||||
|
mimeJPEG = "image/jpeg"
|
||||||
|
mimePNG = "image/png"
|
||||||
|
mimeGIF = "image/gif"
|
||||||
|
mimeWebP = "image/webp"
|
||||||
|
mimeAVIF = "image/avif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||||
|
return []string{
|
||||||
|
mimeJPEG,
|
||||||
|
mimePNG,
|
||||||
|
mimeGIF,
|
||||||
|
mimeWebP,
|
||||||
|
mimeAVIF,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedOutputFormats returns formats this processor can write.
|
||||||
|
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
||||||
|
return []Format{
|
||||||
|
FormatJPEG,
|
||||||
|
FormatPNG,
|
||||||
|
FormatGIF,
|
||||||
|
FormatWebP,
|
||||||
|
FormatAVIF,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatToMIME converts a Format to its MIME type string.
|
||||||
|
func FormatToMIME(format Format) string {
|
||||||
|
switch format {
|
||||||
|
case FormatJPEG:
|
||||||
|
return mimeJPEG
|
||||||
|
case FormatPNG:
|
||||||
|
return mimePNG
|
||||||
|
case FormatWebP:
|
||||||
|
return mimeWebP
|
||||||
|
case FormatGIF:
|
||||||
|
return mimeGIF
|
||||||
|
case FormatAVIF:
|
||||||
|
return mimeAVIF
|
||||||
|
case FormatOriginal:
|
||||||
|
return "application/octet-stream"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
|
// ends, and returns how many images were still being processed then. It
|
||||||
|
// waits by taking each slot in processingSemaphore as it frees up until it
|
||||||
|
// holds them all, or until ctx ends, then gives back the slots it took.
|
||||||
|
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
||||||
|
taken := 0
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
for range taken {
|
||||||
|
<-p.processingSemaphore
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for taken < cap(p.processingSemaphore) {
|
||||||
|
select {
|
||||||
|
case p.processingSemaphore <- struct{}{}:
|
||||||
|
taken++
|
||||||
|
case <-ctx.Done():
|
||||||
|
return len(p.processingSemaphore) - taken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
||||||
|
// processingWaitTimeout for one to free up, and returns the func that gives
|
||||||
|
// it back. A free slot is taken even when ctx has ended; only the wait for
|
||||||
|
// one stops when ctx ends, as the rest of Process does not check ctx.
|
||||||
|
func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
||||||
|
release := func() { <-p.processingSemaphore }
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p.processingSemaphore <- struct{}{}:
|
||||||
|
return release, nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p.processingSemaphore <- struct{}{}:
|
||||||
|
return release, nil
|
||||||
|
case <-time.After(p.processingWaitTimeout):
|
||||||
|
return nil, ErrTooManyImages
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFormat returns the format string from a vips image.
|
||||||
|
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
||||||
|
format := img.Format()
|
||||||
|
switch format {
|
||||||
|
case vips.ImageTypeJPEG:
|
||||||
|
return "jpeg"
|
||||||
|
case vips.ImageTypePNG:
|
||||||
|
return "png"
|
||||||
|
case vips.ImageTypeGIF:
|
||||||
|
return "gif"
|
||||||
|
case vips.ImageTypeWEBP:
|
||||||
|
return "webp"
|
||||||
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
|
return string(FormatAVIF)
|
||||||
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
|
vips.ImageTypeJP2K, vips.ImageTypeJXL:
|
||||||
|
return "unknown"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize resizes the image according to the fit mode.
|
||||||
|
func (p *ImageProcessor) resize(
|
||||||
|
img *vips.ImageRef, width, height int, fit FitMode,
|
||||||
|
) error {
|
||||||
|
switch fit {
|
||||||
|
case FitCover, "":
|
||||||
|
// Resize and crop to fill exact dimensions (default)
|
||||||
|
return img.Thumbnail(width, height, vips.InterestingCentre)
|
||||||
|
|
||||||
|
case FitContain:
|
||||||
|
// Resize to fit within dimensions, maintaining aspect ratio
|
||||||
|
imgW, imgH := img.Width(), img.Height()
|
||||||
|
scaleW := float64(width) / float64(imgW)
|
||||||
|
scaleH := float64(height) / float64(imgH)
|
||||||
|
scale := min(scaleW, scaleH)
|
||||||
|
newW := int(float64(imgW) * scale)
|
||||||
|
newH := int(float64(imgH) * scale)
|
||||||
|
|
||||||
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
|
case FitFill:
|
||||||
|
// Resize to exact dimensions (may distort)
|
||||||
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||||
|
|
||||||
|
case FitInside:
|
||||||
|
// Same as contain, but only shrink
|
||||||
|
if img.Width() <= width && img.Height() <= height {
|
||||||
|
return nil // Already fits
|
||||||
|
}
|
||||||
|
|
||||||
|
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 FitOutside:
|
||||||
|
// Resize so smallest dimension fits, may exceed target on other dimension
|
||||||
|
imgW, imgH := img.Width(), img.Height()
|
||||||
|
scaleW := float64(width) / float64(imgW)
|
||||||
|
scaleH := float64(height) / float64(imgH)
|
||||||
|
scale := max(scaleW, scaleH)
|
||||||
|
newW := int(float64(imgW) * scale)
|
||||||
|
newH := int(float64(imgH) * scale)
|
||||||
|
|
||||||
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultQuality = 85
|
||||||
|
|
||||||
|
// encode encodes an image to the specified format.
|
||||||
|
func (p *ImageProcessor) encode(
|
||||||
|
img *vips.ImageRef, format Format, quality int,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if quality <= 0 {
|
||||||
|
quality = defaultQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
var params vips.ExportParams
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case FormatJPEG:
|
||||||
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeJPEG,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatPNG:
|
||||||
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypePNG,
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatGIF:
|
||||||
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeGIF,
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatWebP:
|
||||||
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeWEBP,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatAVIF:
|
||||||
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeAVIF,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatOriginal:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripping drops the ICC profile as well, and clients show an image
|
||||||
|
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
||||||
|
// libvips' built-in profile; govips' own sRGB path variable is set on
|
||||||
|
// first use but read without a lock, so concurrent requests race on it.
|
||||||
|
if img.HasICCProfile() {
|
||||||
|
err := img.TransformICCProfileWithFallback("srgb", "srgb")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
||||||
|
// GIF, which carries none of them.
|
||||||
|
params.StripMetadata = true
|
||||||
|
|
||||||
|
output, _, err := img.Export(¶ms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFromString converts a format string to Format.
|
||||||
|
func (p *ImageProcessor) formatFromString(format string) Format {
|
||||||
|
switch format {
|
||||||
|
case "jpeg":
|
||||||
|
return FormatJPEG
|
||||||
|
case "png":
|
||||||
|
return FormatPNG
|
||||||
|
case "gif":
|
||||||
|
return FormatGIF
|
||||||
|
case "webp":
|
||||||
|
return FormatWebP
|
||||||
|
case string(FormatAVIF):
|
||||||
|
return FormatAVIF
|
||||||
|
default:
|
||||||
|
return FormatJPEG
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
initVips()
|
||||||
|
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
vips.Shutdown()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestJPEG creates a simple test JPEG image.
|
||||||
|
func createTestJPEG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
// Fill with a gradient
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
img.Set(x, y, color.RGBA{
|
||||||
|
R: uint8((x * 255 / width) & 0xff),
|
||||||
|
G: uint8((y * 255 / height) & 0xff),
|
||||||
|
B: 128,
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestPNG creates a simple test PNG image.
|
||||||
|
func createTestPNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
img.Set(x, y, color.RGBA{
|
||||||
|
R: uint8((x * 255 / width) & 0xff),
|
||||||
|
G: uint8((y * 255 / height) & 0xff),
|
||||||
|
B: 128,
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAVIF reports whether data starts with an AVIF ftyp box.
|
||||||
|
func isAVIF(data []byte) bool {
|
||||||
|
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
brand := string(data[8:12])
|
||||||
|
|
||||||
|
return brand == string(FormatAVIF) || brand == "avis"
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
|
func detectMIME(data []byte) string {
|
||||||
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
|
return mimeJPEG
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||||
|
return mimePNG
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
|
return mimeGIF
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
|
return mimeWebP
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAVIF(data) {
|
||||||
|
return mimeAVIF
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 400, Height: 300},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.Width != 400 {
|
||||||
|
t.Errorf("Process() width = %d, want 400", result.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != 300 {
|
||||||
|
t.Errorf("Process() height = %d, want 300", result.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ContentLength == 0 {
|
||||||
|
t.Error("Process() returned zero content length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid JPEG by reading the content
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectMIME(data)
|
||||||
|
if mime != mimeJPEG {
|
||||||
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 200, Height: 150},
|
||||||
|
Format: FormatPNG,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectMIME(data)
|
||||||
|
if mime != mimePNG {
|
||||||
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAndCheckSize processes a test JPEG of the given input dimensions
|
||||||
|
// with the requested size and asserts the resulting dimensions.
|
||||||
|
func processAndCheckSize(
|
||||||
|
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: size,
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.Width != wantW {
|
||||||
|
t.Errorf("Process() width = %d, want %d", result.Width, wantW)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != wantH {
|
||||||
|
t.Errorf("Process() height = %d, want %d", result.Height, wantH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Width and height 0: keep original size
|
||||||
|
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
||||||
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 400, Height: 400},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitContain,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
// With contain, the image should fit within the box
|
||||||
|
if result.Width > 400 || result.Height > 400 {
|
||||||
|
t.Errorf("Process() size %dx%d exceeds 400x400 box", result.Width, result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 800x600 image, request width=400 height=0
|
||||||
|
// Should scale proportionally to 400x300
|
||||||
|
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 800x600 image, request width=0 height=300
|
||||||
|
// Should scale proportionally to 400x300
|
||||||
|
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 200, Height: 150},
|
||||||
|
Format: FormatPNG,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.Width != 200 {
|
||||||
|
t.Errorf("Process() width = %d, want 200", result.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != 150 {
|
||||||
|
t.Errorf("Process() height = %d, want 150", result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
|
||||||
|
inputFormats := proc.SupportedInputFormats()
|
||||||
|
if len(inputFormats) == 0 {
|
||||||
|
t.Error("SupportedInputFormats() returned empty slice")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFormats := proc.SupportedOutputFormats()
|
||||||
|
if len(outputFormats) == 0 {
|
||||||
|
t.Error("SupportedOutputFormats() returned empty slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Images exceeding MaxInputDimension in either dimension must be
|
||||||
|
// rejected before processing to prevent DoS.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}{
|
||||||
|
{name: "oversized width", width: 10000, height: 100},
|
||||||
|
{name: "oversized height", width: 100, height: 10000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 100, Height: 100},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Process() should reject oversized input images")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrInputTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
|
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 100, Height: 100},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
||||||
|
// given format and asserts the output MIME type and dimensions.
|
||||||
|
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 100, Height: 75},
|
||||||
|
Format: format,
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
// Verify output format
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectMIME(data)
|
||||||
|
if mime != wantMIME {
|
||||||
|
t.Errorf("Output format = %v, want %v", mime, wantMIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dimensions
|
||||||
|
if result.Width != 100 {
|
||||||
|
t.Errorf("Width = %d, want 100", result.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != 75 {
|
||||||
|
t.Errorf("Height = %d, want 75", result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Load test AVIF file
|
||||||
|
input, err := os.ReadFile("testdata/red.avif")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test AVIF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request resize and convert to JPEG
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 2, Height: 2},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
// Verify output is valid JPEG
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectMIME(data)
|
||||||
|
if mime != mimeJPEG {
|
||||||
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a processor with a very small byte limit
|
||||||
|
const limit = 1024
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a valid JPEG that exceeds the byte limit
|
||||||
|
input := createTestJPEG(t, 800, 600) // will be well over 1 KiB
|
||||||
|
if int64(len(input)) <= limit {
|
||||||
|
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 100, Height: 75},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrInputDataTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a small image and set limit well above its size
|
||||||
|
input := createTestJPEG(t, 10, 10)
|
||||||
|
limit := int64(len(input)) * 10 // 10× headroom
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 10, Height: 10},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Passing 0 should use the default
|
||||||
|
proc := New(Params{})
|
||||||
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
|
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passing negative should also use the default
|
||||||
|
proc = New(Params{MaxInputBytes: -1})
|
||||||
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
|
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAndDecode runs input through Process and decodes the output with
|
||||||
|
// vips, so a test can inspect the image a client would receive.
|
||||||
|
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(
|
||||||
|
context.Background(), bytes.NewReader(input), req,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
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 decode output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(output.Close)
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_StripsEXIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// gps-exif.jpg carries GPS coordinates, a camera make, model and serial
|
||||||
|
// number, and a capture time.
|
||||||
|
input, err := os.ReadFile("testdata/gps-exif.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture, err := vips.NewImageFromBuffer(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(fixture.Close)
|
||||||
|
|
||||||
|
if !slices.Contains(fixture.GetFields(), "exif-ifd3-GPSLatitude") {
|
||||||
|
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
||||||
|
}
|
||||||
|
|
||||||
|
formats := []Format{
|
||||||
|
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range formats {
|
||||||
|
t.Run(string(format), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: format})
|
||||||
|
|
||||||
|
if output.HasExif() {
|
||||||
|
t.Errorf("output has EXIF: %v", output.GetExif())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// orientation-6.jpg is stored 16x8, red on the left and blue on the
|
||||||
|
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
|
||||||
|
// Upright it is 8x16, red on top and blue below.
|
||||||
|
input, err := os.ReadFile("testdata/orientation-6.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size Size
|
||||||
|
wantW int
|
||||||
|
wantH int
|
||||||
|
}{
|
||||||
|
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
|
||||||
|
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: FormatPNG,
|
||||||
|
})
|
||||||
|
|
||||||
|
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
||||||
|
t.Fatalf("output is %dx%d, want %dx%d",
|
||||||
|
output.Width(), output.Height(), tt.wantW, tt.wantH)
|
||||||
|
}
|
||||||
|
|
||||||
|
top, err := output.GetPoint(tt.wantW/2, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottom, err := output.GetPoint(tt.wantW/2, tt.wantH-1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if top[0] <= top[2] || bottom[2] <= bottom[0] {
|
||||||
|
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
|
||||||
|
top, bottom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
|
||||||
|
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
|
||||||
|
input, err := os.ReadFile("testdata/display-p3.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := processAndDecode(t, input, &Request{Format: FormatPNG})
|
||||||
|
|
||||||
|
if output.HasICCProfile() {
|
||||||
|
t.Error("output has an ICC profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
pixel, err := output.GetPoint(4, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []float64{255, 0, 0}
|
||||||
|
for i := range want {
|
||||||
|
if math.Abs(pixel[i]-want[i]) > 5 {
|
||||||
|
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/iotest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTestReadFailed is the error the unreadable test input returns.
|
||||||
|
var errTestReadFailed = errors.New("test input cannot be read")
|
||||||
|
|
||||||
|
// readingCounter counts the Process calls reading their input at the same
|
||||||
|
// time and remembers the most there ever were.
|
||||||
|
type readingCounter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
reading int
|
||||||
|
most int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readingCounter) start() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.reading++
|
||||||
|
c.most = max(c.most, c.reading)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readingCounter) stop() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.reading--
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readingCounter) mostReading() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.most
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatedReader is a Process input. Its first Read counts the call in,
|
||||||
|
// reports it on entered and blocks until gate is closed; it counts the call
|
||||||
|
// out when it returns io.EOF. Process reads its input only while it holds a
|
||||||
|
// processing slot, so the count never goes above MaxConcurrentProcessing.
|
||||||
|
type gatedReader struct {
|
||||||
|
data *bytes.Reader
|
||||||
|
gate <-chan struct{}
|
||||||
|
entered chan<- struct{}
|
||||||
|
counter *readingCounter
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *gatedReader) Read(p []byte) (int, error) {
|
||||||
|
if !r.started {
|
||||||
|
r.started = true
|
||||||
|
r.counter.start()
|
||||||
|
|
||||||
|
r.entered <- struct{}{}
|
||||||
|
|
||||||
|
<-r.gate
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.data.Read(p)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
r.counter.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// smallJPEGRequest asks for a 5x5 JPEG.
|
||||||
|
func smallJPEGRequest() *Request {
|
||||||
|
return &Request{
|
||||||
|
Size: Size{Width: 5, Height: 5},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processInBackground runs Process on reader in a new goroutine and sends
|
||||||
|
// its error on results.
|
||||||
|
func processInBackground(
|
||||||
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
|
) {
|
||||||
|
go func() {
|
||||||
|
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
||||||
|
if err == nil {
|
||||||
|
_ = result.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForEntries fails the test unless count Process calls report on
|
||||||
|
// entered within a few seconds.
|
||||||
|
func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for range count {
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Process calls did not start reading their input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, limit := range []int{0, -1} {
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
|
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
||||||
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
||||||
|
limit, got, runtime.GOMAXPROCS(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 3})
|
||||||
|
if got := cap(proc.processingSemaphore); got != 3 {
|
||||||
|
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
||||||
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
||||||
|
// Process until the test lets them go. No more than the limit may be
|
||||||
|
// working at once, and the calls held back must wait for a slot and then
|
||||||
|
// succeed.
|
||||||
|
func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
limit = 2
|
||||||
|
calls = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
|
input := createTestJPEG(t, 50, 50)
|
||||||
|
|
||||||
|
counter := &readingCounter{}
|
||||||
|
gate := make(chan struct{})
|
||||||
|
entered := make(chan struct{}, calls)
|
||||||
|
results := make(chan error, calls)
|
||||||
|
|
||||||
|
openGate := sync.OnceFunc(func() { close(gate) })
|
||||||
|
t.Cleanup(openGate)
|
||||||
|
|
||||||
|
for range calls {
|
||||||
|
processInBackground(proc, &gatedReader{
|
||||||
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
||||||
|
counter: counter,
|
||||||
|
}, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForEntries(t, entered, limit)
|
||||||
|
|
||||||
|
// A call beyond the limit would start reading its input now.
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
t.Fatalf("a Process call started while %d were already working", limit)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
openGate()
|
||||||
|
|
||||||
|
for range calls {
|
||||||
|
err := <-results
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Process() error = %v, want nil once a slot is free", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if most := counter.mostReading(); most > limit {
|
||||||
|
t.Errorf("%d Process calls worked at once, want at most %d", most, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessWaitsThenFailsWhenNoSlotFrees holds the only slot and checks
|
||||||
|
// that another call waits the whole wait timeout, then fails with
|
||||||
|
// ErrTooManyImages instead of processing anyway.
|
||||||
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
||||||
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
|
gate := make(chan struct{})
|
||||||
|
entered := make(chan struct{}, 1)
|
||||||
|
held := make(chan error, 1)
|
||||||
|
|
||||||
|
openGate := sync.OnceFunc(func() { close(gate) })
|
||||||
|
t.Cleanup(openGate)
|
||||||
|
|
||||||
|
processInBackground(proc, &gatedReader{
|
||||||
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
||||||
|
counter: &readingCounter{},
|
||||||
|
}, held)
|
||||||
|
waitForEntries(t, entered, 1)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
|
smallJPEGRequest())
|
||||||
|
if !errors.Is(err, ErrTooManyImages) {
|
||||||
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waited := time.Since(start); waited < proc.processingWaitTimeout {
|
||||||
|
t.Errorf("Process() failed after %v, before waiting %v",
|
||||||
|
waited, proc.processingWaitTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
openGate()
|
||||||
|
|
||||||
|
err = <-held
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Process() holding the slot: error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessReleasesSlotOnError checks that Process gives its slot back
|
||||||
|
// when it fails, whether it fails early or late: with one slot, the slot
|
||||||
|
// must be free after the failure and the next call must succeed.
|
||||||
|
func TestProcessReleasesSlotOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
|
unsupported := smallJPEGRequest()
|
||||||
|
unsupported.Format = "bmp"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input io.Reader
|
||||||
|
req *Request
|
||||||
|
// want is the error Process must return; nil means any error.
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "input cannot be read",
|
||||||
|
input: iotest.ErrReader(errTestReadFailed),
|
||||||
|
req: smallJPEGRequest(),
|
||||||
|
want: errTestReadFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input over the byte limit",
|
||||||
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
||||||
|
req: smallJPEGRequest(),
|
||||||
|
want: ErrInputDataTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input not an image",
|
||||||
|
input: strings.NewReader("not an image"),
|
||||||
|
req: smallJPEGRequest(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output format not supported",
|
||||||
|
input: bytes.NewReader(valid),
|
||||||
|
req: unsupported,
|
||||||
|
want: ErrUnsupportedOutputFormat,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
||||||
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||||
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(proc.processingSemaphore); held != 0 {
|
||||||
|
t.Fatalf("slot still held after the error: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
|
smallJPEGRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWaitForProcessing holds a processing slot with a Process call that
|
||||||
|
// cannot finish reading its input. WaitForProcessing must report that image
|
||||||
|
// when its context ends first, wait for it otherwise, return 0 once it has
|
||||||
|
// finished, and give back the slots it took while waiting.
|
||||||
|
func TestWaitForProcessing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 2})
|
||||||
|
|
||||||
|
gate := make(chan struct{})
|
||||||
|
entered := make(chan struct{}, 1)
|
||||||
|
results := make(chan error, 1)
|
||||||
|
|
||||||
|
openGate := sync.OnceFunc(func() { close(gate) })
|
||||||
|
t.Cleanup(openGate)
|
||||||
|
|
||||||
|
processInBackground(proc, &gatedReader{
|
||||||
|
data: bytes.NewReader(createTestJPEG(t, 10, 10)), gate: gate,
|
||||||
|
entered: entered, counter: &readingCounter{},
|
||||||
|
}, results)
|
||||||
|
waitForEntries(t, entered, 1)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stillProcessing := proc.WaitForProcessing(ctx)
|
||||||
|
t.Logf("WaitForProcessing() after its context ended: %d", stillProcessing)
|
||||||
|
|
||||||
|
if stillProcessing != 1 {
|
||||||
|
t.Errorf("WaitForProcessing() after its context ended = %d, want 1",
|
||||||
|
stillProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
waited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() { waited <- proc.WaitForProcessing(t.Context()) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-waited:
|
||||||
|
t.Fatalf("WaitForProcessing() = %d while an image was being processed",
|
||||||
|
got)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
openGate()
|
||||||
|
|
||||||
|
err := <-results
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Process() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-waited:
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("WaitForProcessing() once processing finished = %d, want 0",
|
||||||
|
got)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("WaitForProcessing() did not return once processing finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(proc.processingSemaphore); held != 0 {
|
||||||
|
t.Errorf("%d slots still held after WaitForProcessing() returned", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 811 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 281 B |
+433
-99
@@ -2,13 +2,19 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache errors.
|
// Cache errors.
|
||||||
@@ -20,17 +26,36 @@ var (
|
|||||||
// HTTP status code for successful fetch.
|
// HTTP status code for successful fetch.
|
||||||
const httpStatusOK = 200
|
const httpStatusOK = 200
|
||||||
|
|
||||||
|
// metaCacheSize is how many variants' content types metaCache holds. A
|
||||||
|
// variant not among them is served as before, reading its .meta file.
|
||||||
|
const metaCacheSize = 10000
|
||||||
|
|
||||||
// CacheConfig holds cache configuration.
|
// CacheConfig holds cache configuration.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
StateDir string
|
StateDir string
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
}
|
|
||||||
|
|
||||||
// variantMeta stores content type for fast cache hits without reading .meta file.
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
type variantMeta struct {
|
// enforces. Zero means no limit is enforced (no eviction).
|
||||||
ContentType string
|
MaxBytes int64
|
||||||
Size int64
|
|
||||||
|
// UseDefaultMaxBytes makes NewCache replace MaxBytes with the
|
||||||
|
// default limit: 75% of the sum of the space free on the filesystem
|
||||||
|
// holding the cache and the bytes the cache already holds, at least
|
||||||
|
// DefaultCacheMaxBytesFloor. The config layer sets this when the
|
||||||
|
// operator omits cache_max_bytes.
|
||||||
|
UseDefaultMaxBytes bool
|
||||||
|
|
||||||
|
// DisableDiskCache turns the disk cache off entirely: no cache
|
||||||
|
// directories are created, lookups always miss, stores are
|
||||||
|
// no-ops, and no eviction machinery runs. The config layer sets
|
||||||
|
// this when the operator configures cache_max_bytes: 0.
|
||||||
|
DisableDiskCache bool
|
||||||
|
|
||||||
|
// Logger receives accounting and eviction log output. A nil
|
||||||
|
// Logger means slog.Default().
|
||||||
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
@@ -40,36 +65,114 @@ type Cache struct {
|
|||||||
variants *VariantStorage // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
srcMetadata *MetadataStorage // source metadata by host/path
|
||||||
config CacheConfig
|
config CacheConfig
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
// disabled means the disk cache is turned off entirely: lookups
|
||||||
metaCache map[VariantKey]variantMeta
|
// always miss, stores are no-ops, and no eviction runs.
|
||||||
|
disabled bool
|
||||||
|
|
||||||
|
// Eviction machinery. The channels are created in NewCache so
|
||||||
|
// stores can signal write pressure without racing StartEviction.
|
||||||
|
// evictionCancel, set by StartEviction, cancels the eviction
|
||||||
|
// goroutine's context.
|
||||||
|
evictionPressure chan struct{}
|
||||||
|
evictionDone chan struct{}
|
||||||
|
evictionCancel context.CancelFunc
|
||||||
|
|
||||||
|
// metaCache holds the content types of the variants most recently
|
||||||
|
// stored or served, so a hit does not read the variant's .meta file.
|
||||||
|
// It never stands in for the variant file, which is always opened.
|
||||||
|
metaCache *lru.Cache[VariantKey, string]
|
||||||
|
|
||||||
|
// contentLocks serializes StoreSource and evictSourceBlob per
|
||||||
|
// content hash, closing the race window between an eviction's row
|
||||||
|
// deletion and its file unlink against a concurrent store of
|
||||||
|
// identical content.
|
||||||
|
contentLocks *contentLock
|
||||||
|
|
||||||
|
// evictSourceBlobTestHook, when set, is invoked by evictSourceBlob
|
||||||
|
// after its row-deletion transaction commits and before the
|
||||||
|
// content file is unlinked. It exists solely so tests can
|
||||||
|
// deterministically pause inside that window to exercise
|
||||||
|
// concurrent stores against it; production code leaves it nil.
|
||||||
|
evictSourceBlobTestHook func(ContentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a new cache instance.
|
// NewCache creates a new cache instance.
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
return newCache(db, config, defaultFreeSpaceProbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCache is NewCache with the free-space probe passed in, so tests
|
||||||
|
// can fake the free space the default limit is worked out from.
|
||||||
|
func newCache(
|
||||||
|
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
|
||||||
|
) (*Cache, error) {
|
||||||
|
log := config.Logger
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Cache{
|
||||||
|
db: db,
|
||||||
|
config: config,
|
||||||
|
log: log,
|
||||||
|
disabled: config.DisableDiskCache,
|
||||||
|
evictionPressure: make(chan struct{}, 1),
|
||||||
|
evictionDone: make(chan struct{}),
|
||||||
|
metaCache: metaCache,
|
||||||
|
contentLocks: newContentLock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.disabled {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srcContent, err := NewContentStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "sources"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants"))
|
variants, err := NewVariantStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "variants"), log,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata"))
|
srcMetadata, err := NewMetadataStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "metadata"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Cache{
|
c.srcContent = srcContent
|
||||||
db: db,
|
c.variants = variants
|
||||||
srcContent: srcContent,
|
c.srcMetadata = srcMetadata
|
||||||
variants: variants,
|
|
||||||
srcMetadata: srcMetadata,
|
if config.UseDefaultMaxBytes {
|
||||||
config: config,
|
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
||||||
metaCache: make(map[VariantKey]variantMeta),
|
if err != nil {
|
||||||
}, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupResult contains the result of a cache lookup.
|
// LookupResult contains the result of a cache lookup.
|
||||||
@@ -81,12 +184,15 @@ type LookupResult struct {
|
|||||||
CacheStatus CacheStatus
|
CacheStatus CacheStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk (no DB access for hits).
|
// Lookup checks if a processed variant exists on disk. Hits touch the
|
||||||
func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, error) {
|
// variant's LRU timestamp; a disabled cache always misses.
|
||||||
|
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
// Check variant storage directly - no DB needed for cache hits
|
// Check variant storage directly - no DB needed for cache hits
|
||||||
if c.variants.Exists(cacheKey) {
|
if !c.disabled && c.variants.Exists(cacheKey) {
|
||||||
|
c.touchVariant(ctx, cacheKey)
|
||||||
|
|
||||||
return &LookupResult{
|
return &LookupResult{
|
||||||
Hit: true,
|
Hit: true,
|
||||||
CacheKey: cacheKey,
|
CacheKey: cacheKey,
|
||||||
@@ -101,27 +207,82 @@ func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVariant returns a reader, size, and content type for a cached variant.
|
// GetVariant returns a reader, size, and content type for a cached
|
||||||
|
// variant. The content type comes from metaCache, or else from the
|
||||||
|
// variant's .meta file and is then kept in metaCache. A variant with
|
||||||
|
// no .meta file is served as application/octet-stream, which is not
|
||||||
|
// kept.
|
||||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||||
return c.variants.LoadWithMeta(cacheKey)
|
if c.disabled {
|
||||||
|
return nil, 0, "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, known := c.metaCache.Get(cacheKey)
|
||||||
|
if !known {
|
||||||
|
return c.loadVariantWithMeta(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
// The file is gone, e.g. deleted outside pixa
|
||||||
|
c.metaCache.Remove(cacheKey)
|
||||||
|
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader, size, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata.
|
// StoreSource stores fetched source content and metadata. On a
|
||||||
|
// disabled cache it is a no-op returning an empty hash.
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *FetchResult,
|
result *httpfetcher.FetchResult,
|
||||||
) (ContentHash, error) {
|
) (ContentHash, error) {
|
||||||
// Store content
|
if c.disabled {
|
||||||
contentHash, size, err := c.srcContent.Store(content)
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the content ourselves (rather than via srcContent.Store,
|
||||||
|
// which would hash internally) so the content hash is known before
|
||||||
|
// any file or database work happens: that lets the entire store be
|
||||||
|
// serialized, per hash, against a concurrent eviction of the same
|
||||||
|
// content below.
|
||||||
|
data, err := io.ReadAll(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read source content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
contentHash := ContentHash(hex.EncodeToString(sum[:]))
|
||||||
|
|
||||||
|
// Hold the content hash's lock for the whole store operation. A
|
||||||
|
// concurrent eviction of this exact hash (the real SHA-256 dedup
|
||||||
|
// case: a different source path whose bytes hash identically)
|
||||||
|
// deletes the accounting rows and unlinks the file inside the same
|
||||||
|
// lock, so the two can never interleave: either this store
|
||||||
|
// completes first (and a subsequent eviction removes it together
|
||||||
|
// with its rows and file, correctly), or eviction completes first
|
||||||
|
// (and this store finds the file already gone and recreates it
|
||||||
|
// fresh) — never a fresh row left pointing at a file eviction is
|
||||||
|
// mid-unlink on.
|
||||||
|
unlock := c.contentLocks.Lock(string(contentHash))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
size, err := c.srcContent.StoreHashed(contentHash, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in database
|
// Store in database
|
||||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||||
headersJSON, _ := json.Marshal(result.Headers)
|
|
||||||
|
headersJSON, err := json.Marshal(result.Headers)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
||||||
@@ -149,39 +310,60 @@ func (c *Cache) StoreSource(
|
|||||||
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store metadata JSON file
|
c.writeMetadataSidecar(req, pathHash, contentHash, result)
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: req.SourceHost,
|
|
||||||
Path: req.SourcePath,
|
|
||||||
Query: req.SourceQuery,
|
|
||||||
ContentHash: string(contentHash),
|
|
||||||
StatusCode: result.StatusCode,
|
|
||||||
ContentType: result.ContentType,
|
|
||||||
ContentLength: result.ContentLength,
|
|
||||||
ResponseHeaders: result.Headers,
|
|
||||||
FetchedAt: time.Now().UTC().Unix(),
|
|
||||||
FetchDurationMs: result.FetchDurationMs,
|
|
||||||
RemoteAddr: result.RemoteAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.srcMetadata.Store(req.SourceHost, pathHash, meta); err != nil {
|
c.notifyWritePressure()
|
||||||
// Non-fatal, we have it in the database
|
|
||||||
_ = err
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentHash, nil
|
return contentHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key.
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
// it in the size accounting. On a disabled cache it is a no-op. The
|
||||||
_, err := c.variants.Store(cacheKey, content, contentType)
|
// accounting insert is best-effort (the startup reconciliation pass
|
||||||
|
// adopts any variant file that misses its accounting row).
|
||||||
|
func (c *Cache) StoreVariant(
|
||||||
|
ctx context.Context, cacheKey VariantKey, content io.Reader, contentType string,
|
||||||
|
) error {
|
||||||
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := c.variants.Store(cacheKey, content, contentType)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.metaCache.Add(cacheKey, contentType)
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
content_type = excluded.content_type,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(cacheKey), size, contentType)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to record variant in size accounting",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifyWritePressure()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values if not.
|
// Returns the content hash and content type if found, or empty values
|
||||||
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
|
||||||
|
// reports no cached source.
|
||||||
|
func (c *Cache) LookupSource(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (ContentHash, string, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
var hashStr, contentType string
|
var hashStr, contentType string
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
@@ -204,15 +386,21 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
|
|||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.touchSourceContent(ctx, contentHash)
|
||||||
|
|
||||||
return contentHash, contentType, nil
|
return contentHash, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error {
|
func (c *Cache) StoreNegative(
|
||||||
|
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
|
||||||
|
) error {
|
||||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at)
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, source_query, status_code,
|
||||||
|
error_message, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
||||||
status_code = excluded.status_code,
|
status_code = excluded.status_code,
|
||||||
@@ -227,46 +415,16 @@ func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkNegativeCache checks if a request is in the negative cache.
|
|
||||||
func (c *Cache) checkNegativeCache(ctx context.Context, req *ImageRequest) (bool, error) {
|
|
||||||
var expiresAt time.Time
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT expires_at FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if time.Now().After(expiresAt) {
|
|
||||||
// Clean up expired entry
|
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
// GetSourceMetadataID returns the source metadata ID for a request.
|
||||||
func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int64, error) {
|
func (c *Cache) GetSourceMetadataID(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT id FROM source_metadata
|
SELECT id FROM source_metadata
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -274,9 +432,16 @@ func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSourceContent returns a reader for cached source content by its hash.
|
// GetSourceContent returns a reader for cached source content by its hash,
|
||||||
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
|
// and the content's size in bytes.
|
||||||
return c.srcContent.Load(contentHash)
|
func (c *Cache) GetSourceContent(
|
||||||
|
contentHash ContentHash,
|
||||||
|
) (io.ReadCloser, int64, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return nil, 0, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.srcContent.LoadWithSize(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired removes expired entries from the cache.
|
// CleanExpired removes expired entries from the cache.
|
||||||
@@ -306,9 +471,22 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get actual item count and total size from content tables
|
// Count and size the cached source images and processed variants. A
|
||||||
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_cache`).Scan(&stats.TotalItems)
|
// disabled cache holds none, whatever rows an earlier run left.
|
||||||
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`).Scan(&stats.TotalSizeBytes)
|
if !c.disabled {
|
||||||
|
err = c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT (SELECT COUNT(*) FROM source_content)
|
||||||
|
+ (SELECT COUNT(*) FROM variant_content)
|
||||||
|
`).Scan(&stats.TotalItems)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute hit rate as a ratio
|
// Compute hit rate as a ratio
|
||||||
if stats.HitCount+stats.MissCount > 0 {
|
if stats.HitCount+stats.MissCount > 0 {
|
||||||
@@ -318,25 +496,181 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
return &stats, nil
|
return &stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementStats increments cache statistics.
|
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||||
|
// fetchBytes bytes, as IncrementUpstreamFetch does.
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||||
|
var err error
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET hit_count = hit_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET hit_count = hit_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET miss_count = miss_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET miss_count = miss_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fetchBytes > 0 {
|
if err != nil {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.IncrementUpstreamFetch(ctx, fetchBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||||
|
// A fetch that read no bytes is not counted.
|
||||||
|
func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
||||||
|
if fetchBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats
|
UPDATE cache_stats
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
SET upstream_fetch_count = upstream_fetch_count + 1,
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`, fetchBytes)
|
`, fetchBytes)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count upstream fetch",
|
||||||
|
"fetch_bytes", fetchBytes, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
|
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE cache_stats
|
||||||
|
SET transform_count = transform_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count transform", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||||
|
// hold: it reads the content type from the variant's .meta file and
|
||||||
|
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||||
|
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
||||||
|
// as one can between a store's writing of the variant file and of its
|
||||||
|
// .meta file, serves application/octet-stream and keeps nothing, so
|
||||||
|
// metaCache only ever holds a type read from a .meta file or passed to
|
||||||
|
// StoreVariant.
|
||||||
|
func (c *Cache) loadVariantWithMeta(
|
||||||
|
cacheKey VariantKey,
|
||||||
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType == "" {
|
||||||
|
return reader, size, fallbackContentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
||||||
|
|
||||||
|
return reader, size, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
||||||
|
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
||||||
|
// database.
|
||||||
|
func (c *Cache) writeMetadataSidecar(
|
||||||
|
req *ImageRequest,
|
||||||
|
pathHash PathHash,
|
||||||
|
contentHash ContentHash,
|
||||||
|
result *httpfetcher.FetchResult,
|
||||||
|
) {
|
||||||
|
meta := &SourceMetadata{
|
||||||
|
Host: req.SourceHost,
|
||||||
|
Path: req.SourcePath,
|
||||||
|
Query: req.SourceQuery,
|
||||||
|
ContentHash: string(contentHash),
|
||||||
|
StatusCode: result.StatusCode,
|
||||||
|
ContentType: result.ContentType,
|
||||||
|
ContentLength: result.ContentLength,
|
||||||
|
ResponseHeaders: result.Headers,
|
||||||
|
FetchedAt: time.Now().UTC().Unix(),
|
||||||
|
FetchDurationMs: result.FetchDurationMs,
|
||||||
|
RemoteAddr: result.RemoteAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to write metadata sidecar",
|
||||||
|
"host", req.SourceHost, "path_hash", pathHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchVariant updates the LRU timestamp of a variant, best-effort:
|
||||||
|
// a failed touch only makes the entry look colder to eviction.
|
||||||
|
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE cache_key = ?
|
||||||
|
`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("failed to touch variant LRU timestamp",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchSourceContent updates the LRU timestamp of a source content
|
||||||
|
// blob, best-effort: a failed touch only makes the blob look colder.
|
||||||
|
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE content_hash = ?
|
||||||
|
`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("failed to touch source content LRU timestamp",
|
||||||
|
"content_hash", contentHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNegativeCache checks if a request is in the negative cache.
|
||||||
|
func (c *Cache) checkNegativeCache(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (bool, error) {
|
||||||
|
var expiresAt time.Time
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT expires_at FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
// Clean up expired entry
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to delete expired negative cache entry",
|
||||||
|
"host", req.SourceHost, "path", req.SourcePath, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -85,14 +86,15 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
INSERT INTO cache_stats (id) VALUES (1);
|
INSERT INTO cache_stats (id) VALUES (1);
|
||||||
`
|
`
|
||||||
|
|
||||||
if _, err := db.Exec(schema); err != nil {
|
_, err = db.ExecContext(t.Context(), schema)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to create schema: %v", err)
|
t.Fatalf("failed to create schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTestCache(t *testing.T) (*Cache, string) {
|
func setupTestCache(t *testing.T) *Cache {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -107,16 +109,18 @@ func setupTestCache(t *testing.T) (*Cache, string) {
|
|||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache, tmpDir
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_LookupMiss(t *testing.T) {
|
func TestCache_LookupMiss(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -138,12 +142,14 @@ func TestCache_LookupMiss(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_StoreAndLookup(t *testing.T) {
|
func TestCache_StoreAndLookup(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -152,12 +158,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
sourceContent := []byte("fake jpeg data")
|
sourceContent := []byte("fake jpeg data")
|
||||||
fetchResult := &FetchResult{
|
fetchResult := &httpfetcher.FetchResult{
|
||||||
ContentType: "image/jpeg",
|
ContentType: testContentTypeJPEG,
|
||||||
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHash, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
contentHash, err := cache.StoreSource(
|
||||||
|
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource() error = %v", err)
|
t.Fatalf("StoreSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,7 +176,9 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("fake webp data")
|
outputContent := []byte("fake webp data")
|
||||||
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err = cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -194,11 +203,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_NegativeCache(t *testing.T) {
|
func TestCache_NegativeCache(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/notfound.jpg",
|
SourcePath: "/photos/notfound.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -222,6 +233,8 @@ func TestCache_NegativeCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -238,7 +251,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/expired.jpg",
|
SourcePath: "/photos/expired.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -265,11 +278,13 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_VariantLookup(t *testing.T) {
|
func TestCache_VariantLookup(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/variant.jpg",
|
SourcePath: "/photos/variant.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -280,7 +295,9 @@ func TestCache_VariantLookup(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("output data")
|
outputContent := []byte("output data")
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -311,11 +328,13 @@ func TestCache_VariantLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/variantct.jpg",
|
SourcePath: "/photos/variantct.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -326,7 +345,9 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("output webp data")
|
outputContent := []byte("output webp data")
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -346,7 +367,8 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
t.Fatalf("GetVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
if contentType != "image/webp" {
|
if contentType != "image/webp" {
|
||||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
||||||
@@ -358,11 +380,13 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_GetVariant(t *testing.T) {
|
func TestCache_GetVariant(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/output.jpg",
|
SourcePath: "/photos/output.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -373,7 +397,9 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("the actual output content")
|
outputContent := []byte("the actual output content")
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
|
err := cache.StoreVariant(
|
||||||
|
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -389,7 +415,8 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
t.Fatalf("GetVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
buf := make([]byte, 100)
|
buf := make([]byte, 100)
|
||||||
n, _ := reader.Read(buf)
|
n, _ := reader.Read(buf)
|
||||||
@@ -400,7 +427,9 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_Stats(t *testing.T) {
|
func TestCache_Stats(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Increment some stats
|
// Increment some stats
|
||||||
@@ -423,6 +452,8 @@ func TestCache_Stats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_CleanExpired(t *testing.T) {
|
func TestCache_CleanExpired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -435,7 +466,8 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
|
|
||||||
// Insert expired negative cache entry directly
|
// Insert expired negative cache entry directly
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, expires_at)
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, source_query, status_code, expires_at)
|
||||||
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -444,7 +476,12 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
|
|
||||||
// Verify it exists
|
// Verify it exists
|
||||||
var count int
|
var count int
|
||||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
|
||||||
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
||||||
}
|
}
|
||||||
@@ -456,13 +493,19 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's gone
|
// Verify it's gone
|
||||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -482,7 +525,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
path := tmpDir + "/" + dir
|
path := tmpDir + "/" + dir
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
t.Errorf("directory %s was not created", dir)
|
t.Errorf("directory %s was not created", dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheMaxBytesFloor is the minimum computed default for the
|
||||||
|
// cache_max_bytes setting: 500 MiB. The floor applies only to the
|
||||||
|
// computed default (when the key is omitted from the configuration),
|
||||||
|
// never to explicitly configured values.
|
||||||
|
const DefaultCacheMaxBytesFloor int64 = 524288000
|
||||||
|
|
||||||
|
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
|
||||||
|
// the 75% share used for the computed default limit as integer
|
||||||
|
// arithmetic (dividing before multiplying avoids overflow).
|
||||||
|
const (
|
||||||
|
freeSpaceFractionNumerator uint64 = 3
|
||||||
|
freeSpaceFractionDenominator uint64 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNegativeBlockSize = errors.New("statfs reported negative block size")
|
||||||
|
|
||||||
|
// FreeSpaceProbeFunc reports the number of free bytes available on the
|
||||||
|
// filesystem containing path. It is a function type so tests can
|
||||||
|
// inject a fake probe instead of depending on the host disk.
|
||||||
|
type FreeSpaceProbeFunc func(path string) (uint64, error)
|
||||||
|
|
||||||
|
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
|
||||||
|
// the given path, as available to unprivileged processes.
|
||||||
|
func defaultFreeSpaceProbe(path string) (uint64, error) {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
|
||||||
|
err := syscall.Statfs(path, &stat)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Bsize < 0 {
|
||||||
|
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
|
||||||
|
return stat.Bavail * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeDefaultMaxBytes returns the default cache size limit: 75% of
|
||||||
|
// the sum of the free bytes probe reports for <state_dir>/cache/ and
|
||||||
|
// the bytes the cache already holds, with a floor of
|
||||||
|
// DefaultCacheMaxBytesFloor. Counting what the cache holds keeps the
|
||||||
|
// limit from shrinking as the cache fills.
|
||||||
|
func (c *Cache) computeDefaultMaxBytes(
|
||||||
|
ctx context.Context, probe FreeSpaceProbeFunc,
|
||||||
|
) (int64, error) {
|
||||||
|
cacheDir := filepath.Join(c.config.StateDir, "cache")
|
||||||
|
|
||||||
|
freeBytes, err := probe(cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"default cache_max_bytes: cannot determine free space for %q: %w",
|
||||||
|
cacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usedBytes, err := c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
|
||||||
|
//nolint:gosec // G115: UsageBytes sums file sizes, never negative
|
||||||
|
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
|
||||||
|
|
||||||
|
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||||
|
computed = min(computed, math.MaxInt64)
|
||||||
|
|
||||||
|
// gosec cannot see that min() above bounds computed, so it reads
|
||||||
|
// this conversion as potentially overflowing. It cannot: computed is
|
||||||
|
// at most math.MaxInt64 on every path here.
|
||||||
|
//nolint:gosec // G115: clamped to MaxInt64 by min above
|
||||||
|
limit := int64(computed)
|
||||||
|
limit = max(limit, DefaultCacheMaxBytesFloor)
|
||||||
|
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Static errors returned by the stub free-space probes below.
|
||||||
|
var (
|
||||||
|
errTestStatfsFailed = errors.New("statfs failed")
|
||||||
|
errTestProbeNotExpected = errors.New("probe must not be called")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestComputeDefaultMaxBytesCountsWhatTheCacheHolds verifies that the
|
||||||
|
// default limit is 75% of the free space plus what the cache already
|
||||||
|
// holds, so a cache filled to its limit keeps that limit across a
|
||||||
|
// restart instead of shrinking to 75% of the space left free.
|
||||||
|
func TestComputeDefaultMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
// Empty cache, 4 GiB free -> 3 GiB default.
|
||||||
|
got, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return 4294967296, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("default for an empty cache with 4 GiB free: %d", got)
|
||||||
|
|
||||||
|
if got != 3221225472 {
|
||||||
|
t.Errorf("default for an empty cache = %d, want 3221225472 (75%% of 4 GiB)",
|
||||||
|
got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache now holds those 3 GiB, which leaves 1 GiB free.
|
||||||
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
string(testVariantKeyOne), 3221225472, testContentTypeWebP,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return 1073741824, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("default for a cache holding 3 GiB with 1 GiB free: %d", got)
|
||||||
|
|
||||||
|
if got != 3221225472 {
|
||||||
|
t.Errorf("default for a cache holding 3 GiB with 1 GiB free = %d, "+
|
||||||
|
"want 3221225472 (75%% of 1 GiB + 3 GiB)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultMaxBytesAppliesFloor verifies that when 75% of the
|
||||||
|
// free space plus what the cache holds is below 500 MiB, the default
|
||||||
|
// is floored at DefaultCacheMaxBytesFloor.
|
||||||
|
func TestComputeDefaultMaxBytesAppliesFloor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
freeBytes uint64
|
||||||
|
}{
|
||||||
|
{name: "100 MiB free", freeBytes: 104857600},
|
||||||
|
{name: "zero free", freeBytes: 0},
|
||||||
|
{name: "just below floor threshold", freeBytes: 699050665},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
got, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return tc.freeBytes, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != DefaultCacheMaxBytesFloor {
|
||||||
|
t.Errorf("computeDefaultMaxBytes = %d, want floor %d",
|
||||||
|
got, DefaultCacheMaxBytesFloor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultMaxBytesPropagatesProbeError verifies that a
|
||||||
|
// failing free-space probe produces an error naming cache_max_bytes,
|
||||||
|
// instead of a silently wrong default.
|
||||||
|
func TestComputeDefaultMaxBytesPropagatesProbeError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
_, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||||
|
func(string) (uint64, error) { return 0, errTestStatfsFailed })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("probe failure must produce an error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "cache_max_bytes") {
|
||||||
|
t.Errorf("error %q does not name cache_max_bytes", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCacheComputesDefaultMaxBytesWhenAsked verifies that with
|
||||||
|
// UseDefaultMaxBytes set, the cache's limit becomes the computed
|
||||||
|
// default, and that the probe is pointed at <state_dir>/cache/, which
|
||||||
|
// must be created first so statfs measures the right filesystem.
|
||||||
|
func TestNewCacheComputesDefaultMaxBytesWhenAsked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
wantCacheDir := filepath.Join(stateDir, "cache")
|
||||||
|
|
||||||
|
var probedPath string
|
||||||
|
|
||||||
|
// 4 GiB free -> 3 GiB default.
|
||||||
|
probe := func(path string) (uint64, error) {
|
||||||
|
probedPath = path
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
||||||
|
path, info, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 4294967296, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := newCache(evictionTestDB(t), CacheConfig{
|
||||||
|
StateDir: stateDir,
|
||||||
|
UseDefaultMaxBytes: true,
|
||||||
|
}, probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCache returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.config.MaxBytes != 3221225472 {
|
||||||
|
t.Errorf("MaxBytes = %d, want computed default 3221225472",
|
||||||
|
cache.config.MaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if probedPath != wantCacheDir {
|
||||||
|
t.Errorf("free space probed at %q, want cache directory %q",
|
||||||
|
probedPath, wantCacheDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCacheKeepsExplicitMaxBytes verifies that without
|
||||||
|
// UseDefaultMaxBytes the cache keeps MaxBytes exactly as given and
|
||||||
|
// never consults the free-space probe.
|
||||||
|
func TestNewCacheKeepsExplicitMaxBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
probe := func(string) (uint64, error) {
|
||||||
|
t.Error("free-space probe must not be consulted for explicit values")
|
||||||
|
|
||||||
|
return 0, errTestProbeNotExpected
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := newCache(evictionTestDB(t), CacheConfig{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
MaxBytes: 1024,
|
||||||
|
}, probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCache returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.config.MaxBytes != 1024 {
|
||||||
|
t.Errorf("MaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
||||||
|
cache.config.MaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// arrivalWait is how long a test gives requests it has started to reach the
|
||||||
|
// point where they wait for a held fetch.
|
||||||
|
const arrivalWait = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// heldFetcher counts the fetches it is asked for and holds each one until
|
||||||
|
// releaseFetches is called, so that a test can have requests arrive while a
|
||||||
|
// fetch is in progress. started receives once for every fetch.
|
||||||
|
type heldFetcher struct {
|
||||||
|
upstream httpfetcher.Fetcher
|
||||||
|
fetches atomic.Int32
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
releaseOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *heldFetcher) Fetch(
|
||||||
|
ctx context.Context, url string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
f.fetches.Add(1)
|
||||||
|
|
||||||
|
f.started <- struct{}{}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-f.release:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.upstream.Fetch(ctx, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseFetches lets every held fetch, and every later one, go on.
|
||||||
|
func (f *heldFetcher) releaseFetches() {
|
||||||
|
f.releaseOnce.Do(func() { close(f.release) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHeldFetchService returns a test service whose fetches go through a
|
||||||
|
// heldFetcher. Its database is limited to one connection: each connection to
|
||||||
|
// an in-memory SQLite database opens a new, empty one, so requests running at
|
||||||
|
// once must share the connection that holds the schema.
|
||||||
|
func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
svc.cache.db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
fetcher := &heldFetcher{
|
||||||
|
upstream: svc.fetcher,
|
||||||
|
started: make(chan struct{}, 100),
|
||||||
|
release: make(chan struct{}),
|
||||||
|
}
|
||||||
|
svc.fetcher = fetcher
|
||||||
|
|
||||||
|
t.Cleanup(fetcher.releaseFetches)
|
||||||
|
|
||||||
|
return svc, fixtures, fetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
||||||
|
// given quality and fit mode. Each call returns a new request, as Get writes
|
||||||
|
// to the request it is given.
|
||||||
|
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest {
|
||||||
|
return &ImageRequest{
|
||||||
|
SourceHost: fixtures.GoodHost,
|
||||||
|
SourcePath: testPathPhoto,
|
||||||
|
Size: Size{Width: 50, Height: 25},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: fit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getResult is what one Get call returned, with the image read out.
|
||||||
|
type getResult struct {
|
||||||
|
image []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// startGet calls Get in a goroutine of its own and delivers what it returned
|
||||||
|
// on the channel.
|
||||||
|
func startGet(
|
||||||
|
ctx context.Context, svc *Service, req *ImageRequest,
|
||||||
|
) <-chan getResult {
|
||||||
|
results := make(chan getResult, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
resp, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
results <- getResult{err: err}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
image, err := io.ReadAll(resp.Content)
|
||||||
|
results <- getResult{image: image, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// jpegSize returns the width and height of the JPEG image in data, or 0 and 0
|
||||||
|
// if data is not one.
|
||||||
|
func jpegSize(data []byte) (int, int) {
|
||||||
|
config, err := jpeg.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.Width, config.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConcurrentMissesShareOneFetch starts several requests for
|
||||||
|
// one uncached variant while the first one's fetch is held. Between them they
|
||||||
|
// must fetch the source once and transcode it once, every one must be answered
|
||||||
|
// with the same 50x25 JPEG, and each must count one miss.
|
||||||
|
func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
const requests = 8
|
||||||
|
|
||||||
|
pending := make([]<-chan getResult, 0, requests)
|
||||||
|
for range requests {
|
||||||
|
pending = append(pending,
|
||||||
|
startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)))
|
||||||
|
}
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
var first []byte
|
||||||
|
|
||||||
|
for i, results := range pending {
|
||||||
|
got := <-results
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("request %d: Get() error = %v", i, got.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if width, height := jpegSize(got.image); width != 50 || height != 25 {
|
||||||
|
t.Errorf("request %d: image is %dx%d, want a 50x25 JPEG", i, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if first == nil {
|
||||||
|
first = got.image
|
||||||
|
} else if !bytes.Equal(got.image, first) {
|
||||||
|
t.Errorf("request %d: image differs from request 0's", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetches := fetcher.fetches.Load(); fetches != 1 {
|
||||||
|
t.Errorf("%d requests made %d upstream fetches, want 1", requests, fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestFS builds the same files the test service's fetcher serves.
|
||||||
|
testFS, _ := NewTestFS(t)
|
||||||
|
|
||||||
|
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
||||||
|
|
||||||
|
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
||||||
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConcurrentVariantsStayApart requests three variants of the
|
||||||
|
// test photo at once that differ only in quality or fit. Each must be made by
|
||||||
|
// a fetch and a transcode of its own, and each answer must be its own variant.
|
||||||
|
func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover))
|
||||||
|
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitContain))
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
select {
|
||||||
|
case <-fetcher.started:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("fewer fetches started than variants requested: " +
|
||||||
|
"variants differing in quality or fit were merged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
images := make(map[string][]byte)
|
||||||
|
|
||||||
|
for _, variant := range []struct {
|
||||||
|
name string
|
||||||
|
results <-chan getResult
|
||||||
|
width, height int
|
||||||
|
}{
|
||||||
|
{"q=85 fit=cover", cover, 50, 25},
|
||||||
|
{"q=40 fit=cover", lowQuality, 50, 25},
|
||||||
|
{"q=85 fit=contain", contain, 25, 25},
|
||||||
|
} {
|
||||||
|
got := <-variant.results
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("%s: Get() error = %v", variant.name, got.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
width, height := jpegSize(got.image)
|
||||||
|
if width != variant.width || height != variant.height {
|
||||||
|
t.Errorf("%s: image is %dx%d, want a %dx%d JPEG", variant.name,
|
||||||
|
width, height, variant.width, variant.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
images[variant.name] = got.image
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(images["q=85 fit=cover"], images["q=40 fit=cover"]) {
|
||||||
|
t.Error("q=40 was answered with the q=85 image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_WaiterStopsWhenItsContextEnds has a second request for a
|
||||||
|
// variant join the first one's held fetch, then ends the second request's
|
||||||
|
// context. The second request must return at once with the context's error,
|
||||||
|
// while the fetch is still held, and the first must still be answered.
|
||||||
|
func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
|
||||||
|
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
||||||
|
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
cancelWaiter()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-waiter:
|
||||||
|
if !errors.Is(got.err, context.Canceled) {
|
||||||
|
t.Errorf("waiting request: Get() error = %v, want %v",
|
||||||
|
got.err, context.Canceled)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("waiting request did not return when its context ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
got := <-first
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("first request: Get() error = %v", got.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if width, height := jpegSize(got.image); width != 50 || height != 25 {
|
||||||
|
t.Errorf("first request: image is %dx%d, want a 50x25 JPEG", width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetches := fetcher.fetches.Load(); fetches != 1 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_FirstRequestLeavingKeepsTheWork ends the context of the
|
||||||
|
// request whose fetch is held, after a second request has joined it. The fetch
|
||||||
|
// and transcode must go on and answer the second request. The first request
|
||||||
|
// waits for its own work, as every request did before misses were shared, and
|
||||||
|
// is answered too.
|
||||||
|
func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
||||||
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
|
||||||
|
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
cancelFirst()
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
for name, results := range map[string]<-chan getResult{
|
||||||
|
"first request": first, "second request": second,
|
||||||
|
} {
|
||||||
|
got := <-results
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("%s: Get() error = %v", name, got.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if width, height := jpegSize(got.image); width != 50 || height != 25 {
|
||||||
|
t.Errorf("%s: image is %dx%d, want a 50x25 JPEG", name, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetches := fetcher.fetches.Load(); fetches != 1 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConcurrentMissesShareAFailure has several requests for an
|
||||||
|
// image that cannot be served arrive while its fetch is held: the one fetch
|
||||||
|
// answers all of them with its error. The request after them is answered from
|
||||||
|
// the negative cache when the failure is kept there, and fetches again when it
|
||||||
|
// is not.
|
||||||
|
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantErr error // what every request at once gets
|
||||||
|
wantNextErr error // what the request after them gets
|
||||||
|
wantFetches int32 // fetches once the request after them is answered
|
||||||
|
}{
|
||||||
|
{"upstream answers 404, kept in the negative cache",
|
||||||
|
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
|
||||||
|
ErrNegativeCached, 1},
|
||||||
|
{"source fails the magic byte check, not kept",
|
||||||
|
"/images/text.png", magic.ErrUnknownFormat, magic.ErrUnknownFormat, 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
request := func() *ImageRequest {
|
||||||
|
req := photoVariant(fixtures, 85, FitCover)
|
||||||
|
req.SourcePath = tc.path
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := make([]<-chan getResult, 0, 4)
|
||||||
|
for range 4 {
|
||||||
|
pending = append(pending, startGet(t.Context(), svc, request()))
|
||||||
|
}
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
fetcher.releaseFetches()
|
||||||
|
|
||||||
|
for i, results := range pending {
|
||||||
|
if got := <-results; !errors.Is(got.err, tc.wantErr) {
|
||||||
|
t.Errorf("request %d: Get() error = %v, want %v", i, got.err, tc.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.Get(t.Context(), request())
|
||||||
|
if !errors.Is(err, tc.wantNextErr) {
|
||||||
|
t.Errorf("next request: Get() error = %v, want %v", err, tc.wantNextErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetches := fetcher.fetches.Load(); fetches != tc.wantFetches {
|
||||||
|
t.Errorf("upstream fetches = %d, want %d", fetches, tc.wantFetches)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_EndedRequestFetchesNothing checks that a request whose
|
||||||
|
// context has already ended when it misses the cache starts no fetch.
|
||||||
|
func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetches := fetcher.fetches.Load(); fetches != 0 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 0", fetches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ReturnsByItsDeadline gives a request a deadline and holds
|
||||||
|
// its fetch until the fetch's context ends, as the fetcher does while it waits
|
||||||
|
// for a free connection to the host. The request must return by its deadline
|
||||||
|
// with the deadline's error.
|
||||||
|
func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, _ := setupHeldFetchService(t)
|
||||||
|
|
||||||
|
const timeout = 200 * time.Millisecond
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
case <-time.After(timeout + time.Second):
|
||||||
|
t.Fatal("request did not return by its deadline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// panickingFetcher panics on every fetch.
|
||||||
|
type panickingFetcher struct{}
|
||||||
|
|
||||||
|
func (panickingFetcher) Fetch(
|
||||||
|
context.Context, string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
panic("upstream fetcher panicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_PanicBecomesAnError checks that a panic while a variant is
|
||||||
|
// being made reaches its request as an error naming the panic, instead of
|
||||||
|
// being raised again.
|
||||||
|
func TestService_Get_PanicBecomesAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
svc.fetcher = panickingFetcher{}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("Get() panicked: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
||||||
|
t.Errorf("Get() error = %v, want one naming the panic", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_PanicIsReportedToSentry checks that a panic while a variant
|
||||||
|
// is being made is reported through the Sentry hub on the request's context,
|
||||||
|
// where the Sentry middleware puts one when sentry_dsn is set.
|
||||||
|
func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
svc.fetcher = panickingFetcher{}
|
||||||
|
|
||||||
|
transport := &sentry.MockTransport{}
|
||||||
|
|
||||||
|
client, err := sentry.NewClient(sentry.ClientOptions{
|
||||||
|
Dsn: "https://abc123@sentry.example.com/42",
|
||||||
|
Transport: transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := sentry.SetHubOnContext(t.Context(),
|
||||||
|
sentry.NewHub(client, sentry.NewScope()))
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("Get() panicked: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
||||||
|
}()
|
||||||
|
|
||||||
|
events := transport.Events()
|
||||||
|
if len(events) != 1 || events[0].Message != "upstream fetcher panicked" {
|
||||||
|
t.Errorf("Sentry events = %+v, want one for the panic", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// contentLock provides per-key mutual exclusion for content-hash keyed
|
||||||
|
// operations. StoreSource and evictSourceBlob each hold a content
|
||||||
|
// hash's lock for the full duration of their file-plus-accounting-row
|
||||||
|
// work, so a store and an eviction racing on identical content bytes
|
||||||
|
// (the real SHA-256 content-addressed dedup case, not a contrived one)
|
||||||
|
// can never interleave: the unlink of an evicted blob's file can never
|
||||||
|
// race the creation of a fresh database row for a concurrently
|
||||||
|
// re-stored copy of the same content. Entries are removed once no
|
||||||
|
// goroutine holds or is waiting for them, so a long-running process
|
||||||
|
// does not accumulate memory proportional to the number of distinct
|
||||||
|
// content hashes it has ever seen.
|
||||||
|
type contentLock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*contentLockEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentLockEntry is one key's exclusion lock plus a count of
|
||||||
|
// goroutines currently holding or waiting to acquire it, used to know
|
||||||
|
// when it is safe to remove the entry from the map.
|
||||||
|
type contentLockEntry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newContentLock creates an empty contentLock.
|
||||||
|
func newContentLock() *contentLock {
|
||||||
|
return &contentLock{entries: make(map[string]*contentLockEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock acquires exclusive access for key, blocking until it is
|
||||||
|
// available, and returns a function that releases it. The caller must
|
||||||
|
// invoke the returned function exactly once to release the lock.
|
||||||
|
func (c *contentLock) Lock(key string) func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok {
|
||||||
|
entry = &contentLockEntry{}
|
||||||
|
c.entries[key] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
entry.mu.Lock()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
entry.count--
|
||||||
|
if entry.count == 0 {
|
||||||
|
delete(c.entries, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestContentLockExcludesSameKey verifies that two goroutines locking
|
||||||
|
// the same key never run their critical sections concurrently.
|
||||||
|
func TestContentLockExcludesSameKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lock := newContentLock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
active atomic.Int32
|
||||||
|
maxSeen int32
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
const goroutines = 20
|
||||||
|
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
for range goroutines {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
unlock := lock.Lock("same-key")
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
n := active.Add(1)
|
||||||
|
|
||||||
|
for {
|
||||||
|
seen := atomic.LoadInt32(&maxSeen)
|
||||||
|
if n <= seen || atomic.CompareAndSwapInt32(&maxSeen, seen, n) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
|
||||||
|
active.Add(-1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if maxSeen != 1 {
|
||||||
|
t.Errorf("max concurrent holders of the same key = %d, want 1", maxSeen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentLockAllowsDifferentKeys verifies that locking distinct
|
||||||
|
// keys does not serialize unrelated work: all goroutines must be able
|
||||||
|
// to enter their critical sections at once, proven by every one of
|
||||||
|
// them reaching the rendezvous point before any is allowed to
|
||||||
|
// proceed.
|
||||||
|
func TestContentLockAllowsDifferentKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lock := newContentLock()
|
||||||
|
|
||||||
|
const goroutines = 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
inside atomic.Int32
|
||||||
|
reached = make(chan struct{}, goroutines)
|
||||||
|
)
|
||||||
|
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
for i := range goroutines {
|
||||||
|
key := string(rune('a' + i))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
unlock := lock.Lock(key)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
inside.Add(1)
|
||||||
|
|
||||||
|
reached <- struct{}{}
|
||||||
|
|
||||||
|
<-release
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every goroutine must reach the rendezvous point (i.e. acquire its
|
||||||
|
// own key's lock) without needing any other to release first. If
|
||||||
|
// keys were incorrectly serialized onto one underlying lock, only
|
||||||
|
// one would get here and this would time out.
|
||||||
|
for i := range goroutines {
|
||||||
|
select {
|
||||||
|
case <-reached:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("only %d/%d goroutines locking distinct keys made progress; "+
|
||||||
|
"keys may be incorrectly serialized", i, goroutines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := inside.Load(); n != goroutines {
|
||||||
|
t.Errorf("goroutines inside their critical section = %d, want %d",
|
||||||
|
n, goroutines)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentLockRemovesEntryAfterUnlock verifies that the internal
|
||||||
|
// entries map does not grow without bound: once no goroutine holds or
|
||||||
|
// awaits a key, its entry is removed.
|
||||||
|
func TestContentLockRemovesEntryAfterUnlock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lock := newContentLock()
|
||||||
|
|
||||||
|
unlock := lock.Lock("k")
|
||||||
|
|
||||||
|
lock.mu.Lock()
|
||||||
|
if _, ok := lock.entries["k"]; !ok {
|
||||||
|
lock.mu.Unlock()
|
||||||
|
t.Fatal("entry missing while lock is held")
|
||||||
|
}
|
||||||
|
lock.mu.Unlock()
|
||||||
|
|
||||||
|
unlock()
|
||||||
|
|
||||||
|
lock.mu.Lock()
|
||||||
|
defer lock.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := lock.entries["k"]; ok {
|
||||||
|
t.Error("entry for key still present after the last holder unlocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Simulate the calculation from processAndStore
|
// Simulate the calculation from processAndStore
|
||||||
fetchBytes := int64(0)
|
fetchBytes := int64(0)
|
||||||
outputSize := int64(100)
|
outputSize := int64(100)
|
||||||
@@ -29,6 +31,8 @@ func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSizePercentNormalCase(t *testing.T) {
|
func TestSizePercentNormalCase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fetchBytes := int64(1000)
|
fetchBytes := int64(1000)
|
||||||
outputSize := int64(500)
|
outputSize := int64(500)
|
||||||
|
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultEvictionInterval is how often the background evictor checks
|
||||||
|
// cache usage against the configured limit, in addition to the
|
||||||
|
// write-pressure wakeups triggered by stores.
|
||||||
|
const DefaultEvictionInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// evictionBatchSize is how many LRU candidates of each class (variants
|
||||||
|
// and source blobs) one eviction pass fetches from the database.
|
||||||
|
const evictionBatchSize = 100
|
||||||
|
|
||||||
|
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
||||||
|
// crashed write) must be before reconciliation removes it. Fresh temp
|
||||||
|
// files may still belong to an in-flight store.
|
||||||
|
const staleTempFileAge = time.Hour
|
||||||
|
|
||||||
|
// sqliteTimestampLayout matches SQLite's CURRENT_TIMESTAMP format, so
|
||||||
|
// timestamps written by reconciliation order correctly against ones
|
||||||
|
// written by the hot path.
|
||||||
|
const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
|
// tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache
|
||||||
|
// writes (".tmp-*" patterns in the storage layer).
|
||||||
|
const tempFilePrefix = ".tmp-"
|
||||||
|
|
||||||
|
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
||||||
|
// to each variant file.
|
||||||
|
const variantMetaSuffix = ".meta"
|
||||||
|
|
||||||
|
// fallbackContentType is the content type given to a variant file that
|
||||||
|
// has no readable .meta sidecar, when it is served or reconciled.
|
||||||
|
const fallbackContentType = "application/octet-stream"
|
||||||
|
|
||||||
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
|
// tracked in the database (source content blobs plus processed
|
||||||
|
// variants). It never scans the cache directories.
|
||||||
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
||||||
|
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
||||||
|
`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
||||||
|
// processed variant (isVariant true, identified by cacheKey) or a
|
||||||
|
// source content blob (identified by contentHash).
|
||||||
|
type evictionCandidate struct {
|
||||||
|
isVariant bool
|
||||||
|
cacheKey VariantKey
|
||||||
|
contentHash ContentHash
|
||||||
|
sizeBytes int64
|
||||||
|
lastAccessedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictToLimit evicts least-recently-used cache entries until total
|
||||||
|
// tracked usage is at or below the configured MaxBytes limit. It is a
|
||||||
|
// no-op when the cache is disabled or no limit is configured.
|
||||||
|
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
||||||
|
if c.disabled || c.config.MaxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
usage, err := c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage <= c.config.MaxBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
freed, err := c.evictBatch(ctx, usage-c.config.MaxBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if freed == 0 {
|
||||||
|
c.log.Warn("cache eviction made no progress",
|
||||||
|
"usage_bytes", usage,
|
||||||
|
"cache_max_bytes", c.config.MaxBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("evicted cache content",
|
||||||
|
"freed_bytes", freed,
|
||||||
|
"usage_bytes", usage-freed,
|
||||||
|
"cache_max_bytes", c.config.MaxBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictBatch fetches one batch of LRU candidates across variants and
|
||||||
|
// source blobs and evicts them oldest-first until excessBytes are
|
||||||
|
// freed or the batch is exhausted. It returns the bytes freed. A
|
||||||
|
// candidate that fails once ctx is cancelled (every one started after
|
||||||
|
// that fails at its first database call) ends the batch with ctx's
|
||||||
|
// error, without a warning.
|
||||||
|
func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error) {
|
||||||
|
candidates, err := c.evictionCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var freed int64
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if freed >= excessBytes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.evictCandidate(ctx, candidate)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return freed, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Warn("failed to evict cache entry",
|
||||||
|
"cache_key", candidate.cacheKey,
|
||||||
|
"content_hash", candidate.contentHash,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
freed += candidate.sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return freed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictCandidate removes a single eviction victim.
|
||||||
|
func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate) error {
|
||||||
|
if candidate.isVariant {
|
||||||
|
return c.evictVariant(ctx, candidate.cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.evictSourceBlob(ctx, candidate.contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionCandidates returns up to evictionBatchSize variants and
|
||||||
|
// evictionBatchSize source blobs, merged into a single list ordered by
|
||||||
|
// last access time (oldest first).
|
||||||
|
func (c *Cache) evictionCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
variants, err := c.variantCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := c.sourceCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the two lists, each already sorted oldest-first. SQLite
|
||||||
|
// CURRENT_TIMESTAMP strings compare correctly lexicographically.
|
||||||
|
merged := make([]evictionCandidate, 0, len(variants)+len(sources))
|
||||||
|
|
||||||
|
for len(variants) > 0 && len(sources) > 0 {
|
||||||
|
if variants[0].lastAccessedAt <= sources[0].lastAccessedAt {
|
||||||
|
merged = append(merged, variants[0])
|
||||||
|
variants = variants[1:]
|
||||||
|
} else {
|
||||||
|
merged = append(merged, sources[0])
|
||||||
|
sources = sources[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, variants...)
|
||||||
|
merged = append(merged, sources...)
|
||||||
|
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCandidates returns the least recently used variants.
|
||||||
|
func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT cache_key, size_bytes, last_accessed_at
|
||||||
|
FROM variant_content
|
||||||
|
ORDER BY last_accessed_at ASC, cache_key ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query variant eviction candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var candidates []evictionCandidate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
candidate := evictionCandidate{isVariant: true}
|
||||||
|
|
||||||
|
var key string
|
||||||
|
|
||||||
|
err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.cacheKey = VariantKey(key)
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("variant candidate iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceCandidates returns the least recently used source blobs. Rows
|
||||||
|
// written before the LRU column existed fall back to fetched_at.
|
||||||
|
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT content_hash, size_bytes,
|
||||||
|
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
||||||
|
FROM source_content
|
||||||
|
ORDER BY lru ASC, content_hash ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var candidates []evictionCandidate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate evictionCandidate
|
||||||
|
|
||||||
|
var hash string
|
||||||
|
|
||||||
|
err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.contentHash = ContentHash(hash)
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("source candidate iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
|
// content and .meta files, so the database never references a deleted
|
||||||
|
// file. The metaCache entry goes before the files; a GetVariant that
|
||||||
|
// read them just before may put it back, and the next GetVariant then
|
||||||
|
// fails to open the file and removes it again.
|
||||||
|
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.metaCache.Remove(cacheKey)
|
||||||
|
|
||||||
|
err = c.variants.Delete(cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceReference identifies one source_metadata row's JSON sidecar.
|
||||||
|
type sourceReference struct {
|
||||||
|
host string
|
||||||
|
pathHash PathHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictSourceBlob removes one source content blob. All source_metadata
|
||||||
|
// rows referencing the blob are deleted together with its
|
||||||
|
// source_content row in a single transaction BEFORE the file is
|
||||||
|
// unlinked: a blob referenced by multiple source paths is only ever
|
||||||
|
// removed together with all of its references, and database rows never
|
||||||
|
// point at deleted files. The JSON metadata sidecars for the removed
|
||||||
|
// rows are deleted afterwards.
|
||||||
|
//
|
||||||
|
// The whole operation holds the content hash's lock (the same one
|
||||||
|
// StoreSource holds for its full store), so a concurrent store of
|
||||||
|
// identical content bytes can never observe the file gone but a row
|
||||||
|
// still present, or insert a fresh row between this transaction's
|
||||||
|
// commit and the file unlink below: it either runs entirely before
|
||||||
|
// this eviction starts, or is blocked until this eviction (row
|
||||||
|
// deletion and unlink together) has fully completed.
|
||||||
|
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
||||||
|
unlock := c.contentLocks.Lock(string(contentHash))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
references, err := c.sourceReferences(ctx, contentHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.evictSourceBlobTestHook != nil {
|
||||||
|
c.evictSourceBlobTestHook(contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only after the rows are gone may the files be removed.
|
||||||
|
for _, reference := range references {
|
||||||
|
err := c.srcMetadata.Delete(reference.host, reference.pathHash)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to delete metadata sidecar",
|
||||||
|
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.srcContent.Delete(contentHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceReferences lists the metadata sidecar locations of every
|
||||||
|
// source_metadata row referencing the given blob.
|
||||||
|
func (c *Cache) sourceReferences(
|
||||||
|
ctx context.Context, contentHash ContentHash,
|
||||||
|
) ([]sourceReference, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
||||||
|
`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var references []sourceReference
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var reference sourceReference
|
||||||
|
|
||||||
|
var pathHash string
|
||||||
|
|
||||||
|
err := rows.Scan(&reference.host, &pathHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reference.pathHash = PathHash(pathHash)
|
||||||
|
references = append(references, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return references, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWritePressure wakes the background evictor after a store, so
|
||||||
|
// eviction under write pressure happens promptly without blocking the
|
||||||
|
// storing request. The notification channel has capacity one and drops
|
||||||
|
// when a wakeup is already pending.
|
||||||
|
func (c *Cache) notifyWritePressure() {
|
||||||
|
if c.disabled || c.config.MaxBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.evictionPressure <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartEviction launches the background eviction goroutine, which
|
||||||
|
// reconciles the database accounting with the cache directories at
|
||||||
|
// startup and again on every periodic tick thereafter, and evicts to
|
||||||
|
// the configured limit on the given periodic interval and on
|
||||||
|
// write-pressure notifications. It is a no-op on a disabled cache or
|
||||||
|
// when already started. The goroutine outlives the caller, so it runs
|
||||||
|
// with its own context, which StopEviction cancels.
|
||||||
|
func (c *Cache) StartEviction(interval time.Duration) {
|
||||||
|
if c.disabled || c.evictionCancel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c.evictionCancel = cancel
|
||||||
|
|
||||||
|
go c.evictionLoop(ctx, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopEviction cancels the background eviction goroutine, which
|
||||||
|
// interrupts a pass in progress, and waits for it to exit or for ctx to
|
||||||
|
// end, whichever comes first. In the second case it returns an error
|
||||||
|
// wrapping ctx's error. It is safe to call when eviction was never
|
||||||
|
// started, and safe to call more than once.
|
||||||
|
func (c *Cache) StopEviction(ctx context.Context) error {
|
||||||
|
if c.evictionCancel == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.evictionCancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.evictionDone:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("cache eviction still running: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionLoop is the body of the background eviction goroutine. It
|
||||||
|
// returns when ctx is cancelled, and starts no pass after that.
|
||||||
|
func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
||||||
|
defer close(c.evictionDone)
|
||||||
|
|
||||||
|
c.runReconciliationPass(ctx)
|
||||||
|
c.runEvictionPass(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Reconciliation walks the cache directories, so it only
|
||||||
|
// runs on the periodic ticker rather than on every
|
||||||
|
// write-pressure wakeup, keeping it off the per-store hot
|
||||||
|
// path. Reusing the eviction interval itself (rather than a
|
||||||
|
// separate, longer one) is a deliberate choice: it is the
|
||||||
|
// simplest option that still bounds how long a store's
|
||||||
|
// best-effort accounting insert can stay silently
|
||||||
|
// unaccounted for to one interval, on a process that is
|
||||||
|
// already running this loop regardless.
|
||||||
|
c.runReconciliationPass(ctx)
|
||||||
|
case <-c.evictionPressure:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.runEvictionPass(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEvictionPass runs one eviction pass, logging failures instead of
|
||||||
|
// propagating them (the loop must keep running). It does nothing once
|
||||||
|
// ctx is cancelled.
|
||||||
|
func (c *Cache) runEvictionPass(ctx context.Context) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.EvictToLimit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("cache eviction pass failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runReconciliationPass runs one reconciliation pass, logging failures
|
||||||
|
// instead of propagating them (the loop must keep running). It does
|
||||||
|
// nothing once ctx is cancelled.
|
||||||
|
func (c *Cache) runReconciliationPass(ctx context.Context) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.reconcileAccounting(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileAccounting synchronizes the database size accounting with
|
||||||
|
// the actual contents of the cache directories. It runs at startup and
|
||||||
|
// again on every periodic eviction tick thereafter, off the request
|
||||||
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
|
// files are missing, removes source blob files the database does not
|
||||||
|
// know (and rows whose files are gone), and sweeps stale temp files
|
||||||
|
// left behind by crashed writes. Running it periodically, not just
|
||||||
|
// once, bounds how long such drift can accumulate unaccounted for on a
|
||||||
|
// long-running process to one eviction interval. Once ctx is cancelled,
|
||||||
|
// it stops at the next file or row and returns ctx's error.
|
||||||
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.reconcileVariantFiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.reconcileVariantRows(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.reconcileSourceFiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.reconcileSourceRows(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantFiles walks the variant storage directory, adopting
|
||||||
|
// files without accounting rows and sweeping stale temp files.
|
||||||
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
|
return filepath.WalkDir(
|
||||||
|
c.variants.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
if strings.HasPrefix(name, tempFilePrefix) {
|
||||||
|
c.sweepStaleTempFile(path, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(name, variantMetaSuffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
|
// has none, using the file's size and modification time.
|
||||||
|
func (c *Cache) adoptVariantFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
||||||
|
) error {
|
||||||
|
var rowExists int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
||||||
|
).Scan(&rowExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat variant file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
|
contentType := c.variantContentTypeFromSidecar(path)
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content
|
||||||
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("adopted untracked variant file into size accounting",
|
||||||
|
"cache_key", cacheKey, "size_bytes", info.Size())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantContentTypeFromSidecar reads the content type from a variant
|
||||||
|
// .meta sidecar, falling back to application/octet-stream.
|
||||||
|
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
||||||
|
//nolint:gosec // path from cache walk
|
||||||
|
metaData, err := os.ReadFile(variantPath + variantMetaSuffix)
|
||||||
|
if err != nil {
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta VariantMeta
|
||||||
|
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
|
// missing, so the database never references deleted content.
|
||||||
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
|
keys, err := c.allVariantKeys(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.variants.Exists(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allVariantKeys returns every tracked variant cache key.
|
||||||
|
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
|
||||||
|
return queryStringColumn[VariantKey](ctx, c.db,
|
||||||
|
`SELECT cache_key FROM variant_content`, "variant keys", "variant key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryStringColumn runs a single-column query and returns the column
|
||||||
|
// values as T. plural names the set for the query and scan failure
|
||||||
|
// messages; singular names one row for the scan and iteration failure
|
||||||
|
// messages.
|
||||||
|
func queryStringColumn[T ~string](
|
||||||
|
ctx context.Context, db *sql.DB, query, plural, singular string,
|
||||||
|
) ([]T, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query %s: %w", plural, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var values []T
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
err := rows.Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan %s: %w", singular, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, T(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s iteration failed: %w", singular, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileSourceFiles walks the source content directory, removing
|
||||||
|
// blob files the database does not track (they are unreachable: source
|
||||||
|
// lookups always go through source_metadata) and sweeping stale temp
|
||||||
|
// files.
|
||||||
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
|
return filepath.WalkDir(
|
||||||
|
c.srcContent.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
if strings.HasPrefix(name, tempFilePrefix) {
|
||||||
|
c.sweepStaleTempFile(path, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
|
// source_content row. Any source_metadata rows referencing the hash
|
||||||
|
// are removed first so no row ever points at a deleted file.
|
||||||
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
|
ctx context.Context, path string, contentHash ContentHash,
|
||||||
|
) error {
|
||||||
|
var rowExists int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
||||||
|
).Scan(&rowExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileSourceRows removes source_content rows (and their metadata
|
||||||
|
// references and sidecars) whose blob files are missing on disk.
|
||||||
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
||||||
|
hashes, err := c.allSourceContentHashes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hash := range hashes {
|
||||||
|
if 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allSourceContentHashes returns every tracked source content hash.
|
||||||
|
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
||||||
|
return queryStringColumn[ContentHash](ctx, c.db,
|
||||||
|
`SELECT content_hash FROM source_content`,
|
||||||
|
"source content hashes", "content hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepStaleTempFile removes a temp file left behind by a crashed
|
||||||
|
// write once it is old enough that no in-flight store can own it.
|
||||||
|
func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(info.ModTime()) < staleTempFileAge {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("removed stale temp file", "path", path)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user